ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,283 @@
|
||||
# Experiment 5-9: Dynamic Form Intent Clarification / 实验 5-9:动态表单生成的意图澄清系统(★★)
|
||||
|
||||
> Companion lab for *AI Agents in Depth*, Chapter 5 — incomplete user intent → one self-contained HTML form with cascading logic; submit JSON back to the Agent.
|
||||
> 《深入理解 AI Agent》第 5 章:信息不完整时动态生成含级联逻辑的 HTML 表单,一次提交 JSON 交回 Agent。
|
||||
|
||||
← [Chapter 5 index / 返回第 5 章目录](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Purpose
|
||||
|
||||
When user requests are **incomplete**, the Agent does not ask one field at a time—it **dynamically generates a self-contained HTML form** to clarify intent in one shot. The form has **cascading logic** (fields shown only under certain choices; dropdown options depend on another field). User **submits once**; the front end returns JSON; the Agent continues the task.
|
||||
|
||||
Acceptance scenario: user says “我想订一张去北京的机票” (book a flight to Beijing). Generated form includes:
|
||||
|
||||
- Departure city (text)
|
||||
- Departure date (date picker)
|
||||
- Trip type (radio: one-way / round-trip)
|
||||
- **Return date (shown only for round-trip)** ← cascade ①: show/hide
|
||||
- Cabin (select: economy / business / first)
|
||||
- **Free checked baggage quota (options depend on cabin)** ← cascade ②: dynamic options
|
||||
|
||||
### Mechanism
|
||||
|
||||
`demo.py` validates in three steps; online and offline share the same mechanism—only “who writes the form” differs:
|
||||
|
||||
- **Online (default)**: send the request to OpenAI; system prompt requires a self-contained HTML (inline `<style>` + `<script>`, cascade show/hide + “submit as JSON”). Needs `OPENAI_API_KEY`.
|
||||
- **Offline (`--offline`)**: no LLM; built-in flight schema **deterministically renders** the same cascading form. No API key. Form is real and browser-usable with **both** cascade types. Auto-falls back offline when `OPENAI_API_KEY` is unset.
|
||||
|
||||
Three steps:
|
||||
|
||||
1. **Generate form**: online model or offline schema → `generated_form.html` (override with `--output`).
|
||||
2. **Structural validation (no browser)**: BeautifulSoup + regex ensure required fields; return-date field has “round-trip only” JS toggle; print script evidence of cascade logic.
|
||||
3. **Simulated submit**: construct user JSON (round-trip), feed Agent; Agent outputs booking summary (model online; template offline)—closes “parse JSON → continue task”.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 5 environment
|
||||
uv sync --locked --python 3.12 --extra ch5
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch5]"
|
||||
|
||||
cd chapter5/dynamic-form
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env # OPENAI_API_KEY (or OPENROUTER_API_KEY fallback)
|
||||
|
||||
python demo.py # online: OpenAI generates (needs API key)
|
||||
python demo.py --offline # offline schema render; no key
|
||||
python demo.py --offline --serve # offline + local server; browser cascade/submit
|
||||
python demo.py --model gpt-5.6 # optional online model override (--offline ignores)
|
||||
python demo.py --request "我想订去东京的机票" # custom vague request
|
||||
python demo.py --help
|
||||
```
|
||||
|
||||
CLI:
|
||||
|
||||
| Flag | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `-r` / `--request TEXT` | Vague user intent | `我想订一张去北京的机票` |
|
||||
| `-o` / `--output PATH` | HTML output path (relative paths resolve vs script dir) | `generated_form.html` |
|
||||
| `--model NAME` | Online model override (`--offline` ignores) | env `MODEL`, else `gpt-5.6-luna` |
|
||||
| `--offline` | Offline deterministic schema | off (auto-on if no key) |
|
||||
| `--serve` | Local HTTP + open browser after generate | off |
|
||||
| `--port N` | Port for `--serve` | `8000` |
|
||||
|
||||
After a successful run:
|
||||
|
||||
- Form at `generated_form.html`—open in a browser (or `--serve`); toggle one-way/round-trip for return-date cascade; change cabin for baggage options; submit prints summary JSON at page bottom.
|
||||
- Terminal prints field checks, cascade evidence, and Agent summary of submitted JSON.
|
||||
|
||||
Env:
|
||||
|
||||
- `OPENAI_API_KEY` (required online; auto offline if missing)
|
||||
- `OPENAI_BASE_URL` (optional OpenAI-compatible endpoint)
|
||||
- `MODEL` (optional; default `gpt-5.6-luna`)
|
||||
|
||||
### Real run output
|
||||
|
||||
**Offline (`--offline`, deterministic, no key)**
|
||||
|
||||
```
|
||||
[步骤 2] 结构化校验表单字段与级联逻辑:
|
||||
[PASS] 出发城市(文本输入)
|
||||
[PASS] 出发日期(日期选择器)
|
||||
[PASS] 旅行类型(单选:单程)
|
||||
[PASS] 旅行类型(单选:往返)
|
||||
[PASS] 返程日期(日期选择器)
|
||||
[PASS] 返程字段级联逻辑(仅往返显示)
|
||||
|
||||
[步骤 3] 解析 JSON 并继续任务,输出订票摘要:
|
||||
已收到您的订票信息:上海 → 北京,出发日期 2026-08-01。
|
||||
行程类型:往返,返程日期 2026-08-07。
|
||||
舱位:公务舱,免费托运 2 件。
|
||||
正在为您检索航班...
|
||||
```
|
||||
|
||||
**Online (default, model `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件。正在为您检索航班...
|
||||
```
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Field naming not fully controllable (online only)**: different models/temps may vary `name`/id. System prompt asks for English names (`departure_city` / `departure_date` / `trip_type` / `return_date`); validation uses **robust keyword/attr matching** (agreed name first, then semantic/text). Occasional drift usually still passes. On `FAIL`, open `generated_form.html`. Offline schema is fully stable.
|
||||
- **Cascade verification**: default is static JS parse + keywords—no real browser. For live cascade, use `--serve` or open the HTML.
|
||||
- **Submit is simulated**: step 3 uses constructed JSON; real systems POST from form `submit` to a backend.
|
||||
- Online quality depends on model; `temperature=0` for more stable reproduction.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 目的
|
||||
|
||||
验证 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 → 继续任务"闭环。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 在仓库根目录使用统一的第 5 章环境
|
||||
uv sync --locked --python 3.12 --extra ch5
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.\.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch5]"
|
||||
|
||||
cd chapter5/dynamic-form
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m 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` 以尽量稳定复现。
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
- Prefer `--offline` / `--serve` for cascade UX without a key. / 无 Key 用 `--offline`;体验级联加 `--serve`。
|
||||
- Commands/code/paths/env vars are identical in both language sections. / 命令、代码、路径与环境变量在中英文两侧保持一致。
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical live campaign for Chapter 5, Experiment 5-9.
|
||||
|
||||
The companion demo historically stopped at static HTML inspection and a
|
||||
constructed submission dictionary. This campaign deliberately has no such
|
||||
fallback: a real model writes the form, Chromium executes its JavaScript, a
|
||||
single browser submit produces the JSON, and a second real model call consumes
|
||||
that exact browser-produced payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from demo import FORM_SYSTEM_PROMPT, PARSE_SYSTEM_PROMPT, validate_form
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REQUEST = "我想订一张去北京的机票"
|
||||
SUBMISSION = {
|
||||
"departure_city": "上海",
|
||||
"departure_date": "2026-08-11",
|
||||
"trip_type": "round_trip",
|
||||
"return_date": "2026-08-18",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def resolve_backend(provider: str, model: str | None) -> tuple[OpenAI, str, str]:
|
||||
choices = {
|
||||
"ark": (
|
||||
os.getenv("ARK_API_KEY"),
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
model or "doubao-seed-1-6-flash-250615",
|
||||
),
|
||||
"moonshot": (
|
||||
os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY"),
|
||||
"https://api.moonshot.cn/v1",
|
||||
model or "kimi-k3",
|
||||
),
|
||||
"openrouter": (
|
||||
os.getenv("OPENROUTER_API_KEY"),
|
||||
"https://openrouter.ai/api/v1",
|
||||
model or "openai/gpt-5.6-luna",
|
||||
),
|
||||
"openai": (
|
||||
os.getenv("OPENAI_API_KEY"),
|
||||
os.getenv("OPENAI_BASE_URL"),
|
||||
model or "gpt-5.6-luna",
|
||||
),
|
||||
"gemini": (
|
||||
os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"),
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
model or "gemini-2.5-flash",
|
||||
),
|
||||
}
|
||||
key, base_url, resolved_model = choices[provider]
|
||||
if not key:
|
||||
raise RuntimeError(f"provider={provider} has no configured credential")
|
||||
kwargs: dict[str, Any] = {
|
||||
"api_key": key,
|
||||
"timeout": 180.0,
|
||||
"max_retries": 4,
|
||||
}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
return OpenAI(**kwargs), resolved_model, base_url or "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def call(
|
||||
client: OpenAI,
|
||||
model: str,
|
||||
purpose: str,
|
||||
messages: list[dict[str, str]],
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
started = time.monotonic()
|
||||
reasoning = any(
|
||||
marker in model.casefold()
|
||||
for marker in ("kimi-k3", "gpt-5", "o1", "o3", "o4", "reasoner", "thinking")
|
||||
)
|
||||
request: dict[str, Any] = {"model": model, "messages": messages}
|
||||
request["temperature"] = 1 if reasoning else 0
|
||||
response = client.chat.completions.create(**request)
|
||||
choice = response.choices[0]
|
||||
if choice.finish_reason == "length":
|
||||
raise RuntimeError(f"{purpose}: provider response was truncated")
|
||||
content = choice.message.content or ""
|
||||
usage = response.usage
|
||||
receipt = {
|
||||
"purpose": purpose,
|
||||
"called_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"latency_s": round(time.monotonic() - started, 3),
|
||||
"request": request,
|
||||
"response": {
|
||||
"id": response.id,
|
||||
"model": response.model,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"content": content,
|
||||
},
|
||||
"usage": {
|
||||
"prompt_tokens": getattr(usage, "prompt_tokens", None),
|
||||
"completion_tokens": getattr(usage, "completion_tokens", None),
|
||||
"total_tokens": getattr(usage, "total_tokens", None),
|
||||
"cached_prompt_tokens": getattr(
|
||||
getattr(usage, "prompt_tokens_details", None), "cached_tokens", None
|
||||
),
|
||||
},
|
||||
}
|
||||
if not receipt["response"]["id"] or not receipt["usage"]["total_tokens"]:
|
||||
raise RuntimeError(f"{purpose}: provider did not return complete receipt metadata")
|
||||
return content, receipt
|
||||
|
||||
|
||||
def strip_fence(text: str) -> str:
|
||||
text = text.strip()
|
||||
text = re.sub(r"^```(?:html)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def browser_submit(html: str, screenshot: Path) -> dict[str, Any]:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1100, "height": 900})
|
||||
page.set_content(html, wait_until="load")
|
||||
page.locator("form").evaluate(
|
||||
"""form => {
|
||||
window.__submitCount = 0;
|
||||
form.addEventListener('submit', () => { window.__submitCount += 1; }, true);
|
||||
}"""
|
||||
)
|
||||
return_field = page.locator('[name="return_date"]')
|
||||
initial_return_visible = return_field.is_visible()
|
||||
page.locator('[name="departure_city"]').fill(SUBMISSION["departure_city"])
|
||||
page.locator('[name="departure_date"]').fill(SUBMISSION["departure_date"])
|
||||
page.locator(
|
||||
'[name="trip_type"][value="round_trip"]'
|
||||
).check()
|
||||
return_field.wait_for(state="visible")
|
||||
return_field.fill(SUBMISSION["return_date"])
|
||||
after_round_trip_visible = return_field.is_visible()
|
||||
page.locator('button[type="submit"], input[type="submit"]').first.click()
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#result') && document.querySelector('#result').textContent.trim().length > 0"
|
||||
)
|
||||
submitted_text = page.locator("#result").text_content() or ""
|
||||
submitted = json.loads(submitted_text)
|
||||
submit_count = page.evaluate("window.__submitCount")
|
||||
screenshot.parent.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=str(screenshot), full_page=True)
|
||||
browser_version = browser.version
|
||||
browser.close()
|
||||
return {
|
||||
"browser": "Chromium",
|
||||
"browser_version": browser_version,
|
||||
"initial_return_visible": initial_return_visible,
|
||||
"after_round_trip_visible": after_round_trip_visible,
|
||||
"submit_count": submit_count,
|
||||
"submitted_json": submitted,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--provider", choices=["ark", "moonshot", "openrouter", "openai", "gemini"], default="ark"
|
||||
)
|
||||
parser.add_argument("--model", default=None)
|
||||
parser.add_argument("--run-id", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
started_utc = dt.datetime.now(dt.timezone.utc)
|
||||
run_id = args.run_id or started_utc.strftime("%Y%m%dT%H%M%SZ-5_9-live-browser")
|
||||
run_dir = HERE / "validation" / "runs" / run_id
|
||||
if run_dir.exists():
|
||||
raise FileExistsError(f"immutable run already exists: {run_dir}")
|
||||
run_dir.mkdir(parents=True)
|
||||
|
||||
client, model, endpoint = resolve_backend(args.provider, args.model)
|
||||
receipts: list[dict[str, Any]] = []
|
||||
generation_messages = [
|
||||
{"role": "system", "content": FORM_SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"用户请求:{REQUEST}\n请为其中缺失的信息生成澄清表单。",
|
||||
},
|
||||
]
|
||||
|
||||
html = ""
|
||||
static_report: dict[str, bool] = {}
|
||||
browser_result: dict[str, Any] | None = None
|
||||
errors: list[str] = []
|
||||
for attempt in range(1, 4):
|
||||
text, receipt = call(
|
||||
client, model, f"generate-form-attempt-{attempt}", generation_messages
|
||||
)
|
||||
receipts.append(receipt)
|
||||
html = strip_fence(text)
|
||||
structural_ok, static_report, _ = validate_form(html)
|
||||
if structural_ok:
|
||||
try:
|
||||
browser_result = browser_submit(html, run_dir / "browser-submitted.png")
|
||||
break
|
||||
except Exception as exc:
|
||||
errors.append(f"browser attempt {attempt}: {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
errors.append(f"structure attempt {attempt}: {static_report}")
|
||||
generation_messages.extend(
|
||||
[
|
||||
{"role": "assistant", "content": text},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"The form failed executable acceptance. Return a complete corrected HTML. "
|
||||
f"Static checks={static_report}; execution errors={errors[-1:]}"
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
if browser_result is None:
|
||||
atomic_json(run_dir / "receipts.json", receipts)
|
||||
raise RuntimeError(f"live browser acceptance failed after three real generations: {errors}")
|
||||
|
||||
html_path = run_dir / "generated_form.html"
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
submitted = browser_result["submitted_json"]
|
||||
parse_messages = [
|
||||
{"role": "system", "content": PARSE_SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"原始请求:{REQUEST}\n浏览器表单实际提交的 JSON 数据:\n"
|
||||
+ json.dumps(submitted, ensure_ascii=False, indent=2)
|
||||
),
|
||||
},
|
||||
]
|
||||
summary, receipt = call(client, model, "continue-after-browser-submit", parse_messages)
|
||||
receipts.append(receipt)
|
||||
atomic_json(run_dir / "receipts.json", receipts)
|
||||
|
||||
gates = {
|
||||
"live_model_generated_complete_html": bool(
|
||||
receipts and "<html" in html.casefold() and "<script" in html.casefold()
|
||||
),
|
||||
"all_manuscript_fields_present": all(static_report.values()),
|
||||
"real_chromium_executed_javascript": bool(browser_result["browser_version"]),
|
||||
"return_date_hidden_before_round_trip": not browser_result["initial_return_visible"],
|
||||
"return_date_visible_after_round_trip": browser_result["after_round_trip_visible"],
|
||||
"user_submitted_exactly_once": browser_result["submit_count"] == 1,
|
||||
"browser_submission_contains_all_values": all(
|
||||
submitted.get(key) == value for key, value in SUBMISSION.items()
|
||||
),
|
||||
"agent_consumed_exact_browser_payload": parse_messages[-1]["content"].endswith(
|
||||
json.dumps(submitted, ensure_ascii=False, indent=2)
|
||||
),
|
||||
"agent_continued_booking_task": all(
|
||||
marker in summary for marker in ("北京", "上海", "2026-08-11", "2026-08-18")
|
||||
),
|
||||
"raw_provider_receipts_complete": all(
|
||||
row["response"]["id"] and row["usage"]["total_tokens"] for row in receipts
|
||||
),
|
||||
"rendered_browser_screenshot_retained": (run_dir / "browser-submitted.png").is_file(),
|
||||
}
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"experiment": "5-9",
|
||||
"run_id": run_id,
|
||||
"started_at_utc": started_utc.isoformat(),
|
||||
"completed_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"provider": args.provider,
|
||||
"endpoint": endpoint,
|
||||
"model": model,
|
||||
"temperature": 1 if any(x in model.casefold() for x in ("kimi-k3", "gpt-5")) else 0,
|
||||
"source": {
|
||||
"manuscript": "book/chapter5.md#实验-5-9",
|
||||
"campaign_sha256": sha256(Path(__file__)),
|
||||
},
|
||||
"input": {"request": REQUEST, "intended_submission": SUBMISSION},
|
||||
"static_validation": static_report,
|
||||
"browser_execution": browser_result,
|
||||
"agent_continuation": summary,
|
||||
"usage": {
|
||||
"calls": len(receipts),
|
||||
"prompt_tokens": sum(r["usage"]["prompt_tokens"] or 0 for r in receipts),
|
||||
"completion_tokens": sum(r["usage"]["completion_tokens"] or 0 for r in receipts),
|
||||
"total_tokens": sum(r["usage"]["total_tokens"] or 0 for r in receipts),
|
||||
"latency_s": round(sum(r["latency_s"] for r in receipts), 3),
|
||||
},
|
||||
"artifacts": {
|
||||
"html": {"path": "generated_form.html", "sha256": sha256(html_path)},
|
||||
"screenshot": {
|
||||
"path": "browser-submitted.png",
|
||||
"sha256": sha256(run_dir / "browser-submitted.png"),
|
||||
},
|
||||
"receipts": {"path": "receipts.json", "sha256": sha256(run_dir / "receipts.json")},
|
||||
},
|
||||
"acceptance_gates": gates,
|
||||
"official_complete": all(gates.values()),
|
||||
"errors_during_repair": errors,
|
||||
}
|
||||
atomic_json(run_dir / "manifest.json", manifest)
|
||||
if manifest["official_complete"]:
|
||||
latest = HERE / "validation" / "latest.json"
|
||||
shutil.copyfile(run_dir / "manifest.json", latest)
|
||||
print(json.dumps({"run_id": run_id, "official_complete": manifest["official_complete"], "gates": gates}, ensure_ascii=False, indent=2))
|
||||
if not manifest["official_complete"]:
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,723 @@
|
||||
"""实验 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", 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()
|
||||
@@ -0,0 +1,13 @@
|
||||
# 必填其一:OpenAI API Key(本实验读取此项)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# 通用兜底:未配置 OPENAI_API_KEY 时自动改走 OpenRouter(否则回落 --offline);
|
||||
# 默认模型 gpt-5.6-luna(gpt-5.x)直连 OpenAI 需组织实名认证,
|
||||
# 故设置了本 key 时会优先走 OpenRouter(route openai/gpt-5.6-luna)。
|
||||
# OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
|
||||
# 可选:切换到兼容 OpenAI 协议的服务端点
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# 可选:指定模型(默认 gpt-5.6-luna)
|
||||
# MODEL=gpt-5.6-luna
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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>机票预订 · 意图澄清表单</h1>
|
||||
<p class="req">原始请求:我想订一张去北京的机票 —— 请一次性补全以下信息(返程日期仅在选择"往返"时出现;行李额度随舱位变化)。</p>
|
||||
<form id="clarify-form">
|
||||
<div class="field"><label class="fld-label" for="departure_city">出发城市</label><input class="fld-input" type="text" id="departure_city" name="departure_city" placeholder="如:上海" required></div>
|
||||
<div class="field"><label class="fld-label" for="departure_date">出发日期</label><input class="fld-input" type="date" id="departure_date" name="departure_date" required></div>
|
||||
<div class="field"><span class="fld-label">旅行类型</span><div class="radio-row"><label class="radio"><input type="radio" name="trip_type" value="one_way" checked> 单程</label><label class="radio"><input type="radio" name="trip_type" value="round_trip"> 往返</label></div></div>
|
||||
<div class="field" id="return_date_field" style="display:none"><label class="fld-label" for="return_date">返程日期</label><input class="fld-input" type="date" id="return_date" name="return_date"></div>
|
||||
<div class="field"><label class="fld-label" for="cabin_class">舱位</label><select class="fld-input" id="cabin_class" name="cabin_class"><option value="economy" selected>经济舱</option><option value="business">公务舱</option><option value="first">头等舱</option></select></div>
|
||||
<div class="field"><label class="fld-label" for="baggage_count">免费托运行李额度</label><select class="fld-input" id="baggage_count" name="baggage_count"></select></div>
|
||||
<button type="submit">提交</button>
|
||||
</form>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
|
||||
const FORM_CONFIG = {"fields": [{"name": "departure_city"}, {"name": "departure_date"}, {"name": "trip_type"}, {"name": "return_date", "container_id": "return_date_field", "show_when": {"field": "trip_type", "equals": "round_trip"}}, {"name": "cabin_class"}, {"name": "baggage_count", "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)"}]}}}], "constants": {"destination_city": "北京"}};
|
||||
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);
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
openai>=1.30.0
|
||||
beautifulsoup4>=4.12
|
||||
python-dotenv>=1.0
|
||||
playwright>=1.45.0
|
||||
@@ -0,0 +1,57 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_request", "expected_destination"),
|
||||
[
|
||||
("我想订一张去上海的机票", "上海"),
|
||||
("我想订一张去上海的往返机票", "上海"),
|
||||
("我想订一张去上海往返的机票", "上海"),
|
||||
("我想订一张去上海往返机票", "上海"),
|
||||
("我想订一张去广州的单程机票", "广州"),
|
||||
("我想订一张去广州单程的票", "广州"),
|
||||
("我想订一张去广州单程机票", "广州"),
|
||||
("我想订一张去深圳的航班", "深圳"),
|
||||
("我想订一张去深圳往返的航班", "深圳"),
|
||||
("我想订一张去杭州单程的票", "杭州"),
|
||||
],
|
||||
ids=[
|
||||
"simple-jipiao",
|
||||
"round-trip-with-de-before",
|
||||
"round-trip-with-de-after",
|
||||
"round-trip-without-de",
|
||||
"one-way-with-de-before",
|
||||
"one-way-with-de-after",
|
||||
"one-way-without-de",
|
||||
"hangban-simple",
|
||||
"hangban-round-trip",
|
||||
"piao-one-way",
|
||||
],
|
||||
)
|
||||
def test_offline_cli_extracts_only_the_destination(user_request, expected_destination, tmp_path):
|
||||
"""Trip-type modifiers must not become part of the submitted destination."""
|
||||
demo = Path(__file__).with_name("demo.py")
|
||||
output_html = tmp_path / "form.html"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(demo),
|
||||
"--offline",
|
||||
"--request",
|
||||
user_request,
|
||||
"--output",
|
||||
str(output_html),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert f'"destination_city": "{expected_destination}"' in result.stdout
|
||||
assert f"已收到您的订票信息:上海 → {expected_destination},出发日期" in result.stdout
|
||||
form_html_content = output_html.read_text(encoding="utf-8")
|
||||
assert f'"destination_city": "{expected_destination}"' in form_html_content
|
||||
@@ -0,0 +1,60 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_demo_path = Path(__file__).parent / "demo.py"
|
||||
_spec = importlib.util.spec_from_file_location("dynamic_form_demo", _demo_path)
|
||||
_demo = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_demo)
|
||||
summarize_offline = _demo.summarize_offline
|
||||
|
||||
|
||||
def test_summarize_offline_handles_numeric_zero_baggage():
|
||||
"""Prove summarize_offline treats numeric 0 baggage count as no free baggage.
|
||||
|
||||
When submitted form data contains integer 0 (e.g. from JSON deserialization or
|
||||
Python API callers), summarize_offline must format ",无免费托运" rather than
|
||||
",免费托运 0 件".
|
||||
"""
|
||||
submitted = {
|
||||
"departure_city": "北京",
|
||||
"destination_city": "上海",
|
||||
"departure_date": "2026-08-10",
|
||||
"cabin_class": "economy",
|
||||
"baggage_count": 0,
|
||||
}
|
||||
summary = summarize_offline(submitted)
|
||||
assert summary == (
|
||||
"已收到您的订票信息:北京 → 上海,出发日期 2026-08-10。\n"
|
||||
"行程类型:单程。\n"
|
||||
"舱位:经济舱,无免费托运。\n"
|
||||
"正在为您检索航班..."
|
||||
)
|
||||
assert ",无免费托运" in summary
|
||||
assert "免费托运 0 件" not in summary
|
||||
|
||||
|
||||
def test_summarize_offline_handles_string_zero_baggage():
|
||||
"""Prove summarize_offline treats string '0' baggage count as no free baggage."""
|
||||
submitted = {
|
||||
"departure_city": "北京",
|
||||
"destination_city": "上海",
|
||||
"departure_date": "2026-08-10",
|
||||
"cabin_class": "economy",
|
||||
"baggage_count": "0",
|
||||
}
|
||||
summary = summarize_offline(submitted)
|
||||
assert ",无免费托运" in summary
|
||||
assert "免费托运 0 件" not in summary
|
||||
|
||||
|
||||
def test_summarize_offline_handles_positive_baggage():
|
||||
"""Prove summarize_offline formats positive baggage count correctly."""
|
||||
submitted = {
|
||||
"departure_city": "北京",
|
||||
"destination_city": "上海",
|
||||
"departure_date": "2026-08-10",
|
||||
"cabin_class": "economy",
|
||||
"baggage_count": 1,
|
||||
}
|
||||
summary = summarize_offline(submitted)
|
||||
assert ",免费托运 1 件" in summary
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"experiment": "5-9",
|
||||
"run_id": "20260729T212542Z-5_9-live-browser",
|
||||
"started_at_utc": "2026-07-29T21:25:42.619262+00:00",
|
||||
"completed_at_utc": "2026-07-29T21:25:47.868053+00:00",
|
||||
"provider": "ark",
|
||||
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"temperature": 0,
|
||||
"source": {
|
||||
"manuscript": "book/chapter5.md#实验-5-9",
|
||||
"campaign_sha256": "f1110f5b6b07ea6e8ba4a7a5270fcc85f25752fdc01056bb73f1abea9ee4a8cf"
|
||||
},
|
||||
"input": {
|
||||
"request": "我想订一张去北京的机票",
|
||||
"intended_submission": {
|
||||
"departure_city": "上海",
|
||||
"departure_date": "2026-08-11",
|
||||
"trip_type": "round_trip",
|
||||
"return_date": "2026-08-18"
|
||||
}
|
||||
},
|
||||
"static_validation": {
|
||||
"出发城市(文本输入)": true,
|
||||
"出发日期(日期选择器)": true,
|
||||
"旅行类型(单选:单程)": true,
|
||||
"旅行类型(单选:往返)": true,
|
||||
"返程日期(日期选择器)": true,
|
||||
"返程字段级联逻辑(仅往返显示)": true
|
||||
},
|
||||
"browser_execution": {
|
||||
"browser": "Chromium",
|
||||
"browser_version": "139.0.7258.5",
|
||||
"initial_return_visible": false,
|
||||
"after_round_trip_visible": true,
|
||||
"submit_count": 1,
|
||||
"submitted_json": {
|
||||
"departure_city": "上海",
|
||||
"departure_date": "2026-08-11",
|
||||
"trip_type": "round_trip",
|
||||
"return_date": "2026-08-18"
|
||||
}
|
||||
},
|
||||
"agent_continuation": "订上海至北京往返机票,去程日期2026-08-11,返程日期2026-08-18。正在为您检索航班...",
|
||||
"usage": {
|
||||
"calls": 2,
|
||||
"prompt_tokens": 690,
|
||||
"completion_tokens": 793,
|
||||
"total_tokens": 1483,
|
||||
"latency_s": 4.516
|
||||
},
|
||||
"artifacts": {
|
||||
"html": {
|
||||
"path": "generated_form.html",
|
||||
"sha256": "676e39fc18181aeb8f2b851e65df281aef56c8e64e49f447b9ac928788ebadcf"
|
||||
},
|
||||
"screenshot": {
|
||||
"path": "browser-submitted.png",
|
||||
"sha256": "d404555c7699e8bc4474791dd147f0beb7222dfc36fce53472c479ba97b91bdd"
|
||||
},
|
||||
"receipts": {
|
||||
"path": "receipts.json",
|
||||
"sha256": "38d5160495f123f4a3847d11bf8ff8a16e0e2236b80c3d086f6d7691baac2532"
|
||||
}
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"live_model_generated_complete_html": true,
|
||||
"all_manuscript_fields_present": true,
|
||||
"real_chromium_executed_javascript": true,
|
||||
"return_date_hidden_before_round_trip": true,
|
||||
"return_date_visible_after_round_trip": true,
|
||||
"user_submitted_exactly_once": true,
|
||||
"browser_submission_contains_all_values": true,
|
||||
"agent_consumed_exact_browser_payload": true,
|
||||
"agent_continued_booking_task": true,
|
||||
"raw_provider_receipts_complete": true,
|
||||
"rendered_browser_screenshot_retained": true
|
||||
},
|
||||
"official_complete": true,
|
||||
"errors_during_repair": []
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
+51
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>机票预订澄清表单</title>
|
||||
<style>
|
||||
#return_date_field { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form id="bookingForm">
|
||||
<label for="departure_city">出发城市:</label>
|
||||
<input type="text" id="departure_city" name="departure_city" required><br>
|
||||
|
||||
<label for="departure_date">出发日期:</label>
|
||||
<input type="date" id="departure_date" name="departure_date" required><br>
|
||||
|
||||
<label>旅行类型:</label>
|
||||
<input type="radio" id="one_way" name="trip_type" value="one_way" checked>单程
|
||||
<input type="radio" id="round_trip" name="trip_type" value="round_trip">往返<br>
|
||||
|
||||
<div id="return_date_field">
|
||||
<label for="return_date">返程日期:</label>
|
||||
<input type="date" id="return_date" name="return_date">
|
||||
</div><br>
|
||||
|
||||
<input type="submit" value="提交">
|
||||
</form>
|
||||
<pre id="result"></pre>
|
||||
|
||||
<script>
|
||||
document.getElementById('round_trip').addEventListener('change', function() {
|
||||
document.getElementById('return_date_field').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
document.getElementById('one_way').addEventListener('change', function() {
|
||||
document.getElementById('return_date_field').style.display = this.checked ? 'none' : 'block';
|
||||
});
|
||||
|
||||
document.getElementById('bookingForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
departure_city: document.getElementById('departure_city').value,
|
||||
departure_date: document.getElementById('departure_date').value,
|
||||
trip_type: document.querySelector('input[name="trip_type"]:checked').value,
|
||||
return_date: document.getElementById('return_date').value
|
||||
};
|
||||
document.getElementById('result').textContent = JSON.stringify(formData, null, 2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"experiment": "5-9",
|
||||
"run_id": "20260729T212542Z-5_9-live-browser",
|
||||
"started_at_utc": "2026-07-29T21:25:42.619262+00:00",
|
||||
"completed_at_utc": "2026-07-29T21:25:47.868053+00:00",
|
||||
"provider": "ark",
|
||||
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"temperature": 0,
|
||||
"source": {
|
||||
"manuscript": "book/chapter5.md#实验-5-9",
|
||||
"campaign_sha256": "f1110f5b6b07ea6e8ba4a7a5270fcc85f25752fdc01056bb73f1abea9ee4a8cf"
|
||||
},
|
||||
"input": {
|
||||
"request": "我想订一张去北京的机票",
|
||||
"intended_submission": {
|
||||
"departure_city": "上海",
|
||||
"departure_date": "2026-08-11",
|
||||
"trip_type": "round_trip",
|
||||
"return_date": "2026-08-18"
|
||||
}
|
||||
},
|
||||
"static_validation": {
|
||||
"出发城市(文本输入)": true,
|
||||
"出发日期(日期选择器)": true,
|
||||
"旅行类型(单选:单程)": true,
|
||||
"旅行类型(单选:往返)": true,
|
||||
"返程日期(日期选择器)": true,
|
||||
"返程字段级联逻辑(仅往返显示)": true
|
||||
},
|
||||
"browser_execution": {
|
||||
"browser": "Chromium",
|
||||
"browser_version": "139.0.7258.5",
|
||||
"initial_return_visible": false,
|
||||
"after_round_trip_visible": true,
|
||||
"submit_count": 1,
|
||||
"submitted_json": {
|
||||
"departure_city": "上海",
|
||||
"departure_date": "2026-08-11",
|
||||
"trip_type": "round_trip",
|
||||
"return_date": "2026-08-18"
|
||||
}
|
||||
},
|
||||
"agent_continuation": "订上海至北京往返机票,去程日期2026-08-11,返程日期2026-08-18。正在为您检索航班...",
|
||||
"usage": {
|
||||
"calls": 2,
|
||||
"prompt_tokens": 690,
|
||||
"completion_tokens": 793,
|
||||
"total_tokens": 1483,
|
||||
"latency_s": 4.516
|
||||
},
|
||||
"artifacts": {
|
||||
"html": {
|
||||
"path": "generated_form.html",
|
||||
"sha256": "676e39fc18181aeb8f2b851e65df281aef56c8e64e49f447b9ac928788ebadcf"
|
||||
},
|
||||
"screenshot": {
|
||||
"path": "browser-submitted.png",
|
||||
"sha256": "d404555c7699e8bc4474791dd147f0beb7222dfc36fce53472c479ba97b91bdd"
|
||||
},
|
||||
"receipts": {
|
||||
"path": "receipts.json",
|
||||
"sha256": "38d5160495f123f4a3847d11bf8ff8a16e0e2236b80c3d086f6d7691baac2532"
|
||||
}
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"live_model_generated_complete_html": true,
|
||||
"all_manuscript_fields_present": true,
|
||||
"real_chromium_executed_javascript": true,
|
||||
"return_date_hidden_before_round_trip": true,
|
||||
"return_date_visible_after_round_trip": true,
|
||||
"user_submitted_exactly_once": true,
|
||||
"browser_submission_contains_all_values": true,
|
||||
"agent_consumed_exact_browser_payload": true,
|
||||
"agent_continued_booking_task": true,
|
||||
"raw_provider_receipts_complete": true,
|
||||
"rendered_browser_screenshot_retained": true
|
||||
},
|
||||
"official_complete": true,
|
||||
"errors_during_repair": []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"purpose": "generate-form-attempt-1",
|
||||
"called_at_utc": "2026-07-29T21:25:46.329389+00:00",
|
||||
"latency_s": 3.64,
|
||||
"request": {
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个\"意图澄清\"助手。用户会给出一个信息不完整的请求,\n你的任务不是直接追问,而是**生成一个自包含的 HTML 表单**,让用户一次性补全所有\n缺失信息。\n\n严格要求(订机票场景):\n1. 表单必须包含以下字段,字段的 name 属性必须使用给定的英文标识:\n - 出发城市:文本输入框,name=\"departure_city\"\n - 出发日期:日期选择器 <input type=\"date\">,name=\"departure_date\"\n - 旅行类型:单选按钮 <input type=\"radio\" name=\"trip_type\">,两个选项\n value=\"one_way\"(单程)和 value=\"round_trip\"(往返)\n - 返程日期:日期选择器,name=\"return_date\",放在 id=\"return_date_field\" 的\n 容器里\n2. **级联逻辑(关键)**:返程日期字段默认隐藏,只有当旅行类型选择\"往返\"\n (round_trip) 时才通过 JavaScript 显示出来;选回\"单程\"时再次隐藏。\n3. 提交时用 JavaScript 阻止默认提交,把所有字段汇总为一个 JSON 对象,\n key 使用上面的英文 name,并显示在 id=\"result\" 的元素里\n (例如 <pre id=\"result\"></pre>)。\n4. 输出必须是**完整、自包含**的 HTML(含 <style> 和 <script>,内联,不引用外部\n 资源),可直接保存为 .html 文件在浏览器打开。\n\n只输出 HTML 代码本身,不要任何解释文字,不要用 markdown 代码块包裹。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "用户请求:我想订一张去北京的机票\n请为其中缺失的信息生成澄清表单。"
|
||||
}
|
||||
],
|
||||
"temperature": 0
|
||||
},
|
||||
"response": {
|
||||
"id": "02178536034303104b7fd0428efab78207837433ca5fe67baa6ba",
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"finish_reason": "stop",
|
||||
"content": "<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <title>机票预订澄清表单</title>\n <style>\n #return_date_field { display: none; }\n </style>\n</head>\n<body>\n <form id=\"bookingForm\">\n <label for=\"departure_city\">出发城市:</label>\n <input type=\"text\" id=\"departure_city\" name=\"departure_city\" required><br>\n\n <label for=\"departure_date\">出发日期:</label>\n <input type=\"date\" id=\"departure_date\" name=\"departure_date\" required><br>\n\n <label>旅行类型:</label>\n <input type=\"radio\" id=\"one_way\" name=\"trip_type\" value=\"one_way\" checked>单程\n <input type=\"radio\" id=\"round_trip\" name=\"trip_type\" value=\"round_trip\">往返<br>\n\n <div id=\"return_date_field\">\n <label for=\"return_date\">返程日期:</label>\n <input type=\"date\" id=\"return_date\" name=\"return_date\">\n </div><br>\n\n <input type=\"submit\" value=\"提交\">\n </form>\n <pre id=\"result\"></pre>\n\n <script>\n document.getElementById('round_trip').addEventListener('change', function() {\n document.getElementById('return_date_field').style.display = this.checked ? 'block' : 'none';\n });\n document.getElementById('one_way').addEventListener('change', function() {\n document.getElementById('return_date_field').style.display = this.checked ? 'none' : 'block';\n });\n\n document.getElementById('bookingForm').addEventListener('submit', function(e) {\n e.preventDefault();\n const formData = {\n departure_city: document.getElementById('departure_city').value,\n departure_date: document.getElementById('departure_date').value,\n trip_type: document.querySelector('input[name=\"trip_type\"]:checked').value,\n return_date: document.getElementById('return_date').value\n };\n document.getElementById('result').textContent = JSON.stringify(formData, null, 2);\n });\n </script>\n</body>\n</html>"
|
||||
},
|
||||
"usage": {
|
||||
"prompt_tokens": 449,
|
||||
"completion_tokens": 654,
|
||||
"total_tokens": 1103,
|
||||
"cached_prompt_tokens": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"purpose": "continue-after-browser-submit",
|
||||
"called_at_utc": "2026-07-29T21:25:47.866760+00:00",
|
||||
"latency_s": 0.876,
|
||||
"request": {
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是订机票助手。用户已经通过澄清表单一次性提交了 JSON 格式\n的补全信息。请解析这些信息并给出一段简洁的中文\"订票摘要\",确认航段、日期、行程类型。\n如果是单程(one_way)则不要提返程;如果是往返(round_trip)则必须包含返程日期。\n最后追加一句下一步操作提示(如\"正在为您检索航班...\")。只输出摘要文本。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "原始请求:我想订一张去北京的机票\n浏览器表单实际提交的 JSON 数据:\n{\n \"departure_city\": \"上海\",\n \"departure_date\": \"2026-08-11\",\n \"trip_type\": \"round_trip\",\n \"return_date\": \"2026-08-18\"\n}"
|
||||
}
|
||||
],
|
||||
"temperature": 0
|
||||
},
|
||||
"response": {
|
||||
"id": "02178536034718104b7fd0428efab78207837433ca5fe67358839",
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"finish_reason": "stop",
|
||||
"content": "订上海至北京往返机票,去程日期2026-08-11,返程日期2026-08-18。正在为您检索航班..."
|
||||
},
|
||||
"usage": {
|
||||
"prompt_tokens": 241,
|
||||
"completion_tokens": 139,
|
||||
"total_tokens": 380,
|
||||
"cached_prompt_tokens": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user