项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

518 行
23 KiB
Markdown

# bench_env grounded evaluation mode
> Companion docs: authoring workflow — [`TASK_AUTHORING_GUIDE.md`](TASK_AUTHORING_GUIDE.md); hard rules and forbidden patterns — [`TASK_CODE_SPEC.md`](TASK_CODE_SPEC.md).
## 1. Overview
Grounded evaluation uses the `AnswerSheet` app to turn the Agent's answer submission into **a precise UI-state-based judgment**, eliminating the false positives caused by fuzzy natural-language matching in text mode.
**Two evaluation modes coexist**:
- **grounded mode** (default, `--eval-mode grounded`): the Agent fills out a form in the AnswerSheet app; the framework reads UI state to judge
- **text mode** (`--eval-mode text`): the Agent calls `ANSWER` and its text is fuzzy-matched (`match_value`)
---
## 2. Architecture: two evaluation paths
In grounded mode, the runner picks a path based on the task's shape:
```
Task has answer_fields?
┌────┴────┐
│ No │ Yes
│ ▼
│ Task has custom check_goals?
│ │
│ ┌────┴────┐
│ │ No │ Yes
│ ▼ ▼
│ Path A Path B
text-mode fallback
(task.evaluate)
```
### Path A: structured precise match (`build_grounded_checks`)
**When**: task has no custom `check_goals` (typical case: a pure `AnswerTask`).
Task must provide `get_expected_response` (the `AnswerTask` base class derives it from `get_answer()` by default).
**Behavior**:
1. Reads each form field from `answer_sheet` state in order
2. Calls `task.get_expected_response(input)` to get the expected value per field
3. Uses `_match_grounded_field` to **match field-by-field** (exact / number / date / time)
4. **Does not call** `check_goals`
**Advantage**: per-field isolated matching — no cross-field value contamination.
### Path B: hydrate `input.answer`
**When**: task has a custom `check_goals` (whether `AnswerTask` or `BaseTask`).
**Behavior**:
1. Reads every field value from `answer_sheet` state
2. Joins values with `", "` into a single string and injects it as `input.answer`
3. Calls `task.evaluate()` → goes through the normal `check_goals` judging
**Advantage**: preserves custom judging logic (e.g., checking state changes and answer correctness together).
### Decision (runner code)
```python
# Walk the MRO to find the class that actually defined check_goals
_cg_definer = next(
(c for c in type(task).__mro__ if "check_goals" in c.__dict__), BaseTask
)
# BaseTask (empty impl) and AnswerTask (answer-only matcher) don't count as custom
has_custom_cg = _cg_definer not in (BaseTask, AnswerTask)
if not has_custom_cg:
# Path A: structured precise match
build_grounded_checks(task, judge_input, sheet_state)
else:
# Path B: hydrate input.answer (requires submitted=True)
```
> **Note**: walking the MRO ensures `check_goals` defined on intermediate base classes (e.g., `CriteriaTask`) is not skipped.
> Path B also checks the `submitted` flag — answers are not injected if the Agent never tapped Submit.
---
## 3. Adding Grounded support to a task
### 3.1 Pure-query task (AnswerTask, no custom check_goals)
Just add `answer_fields`; the framework handles the rest:
```python
class CountAlarms(AnswerTask):
answer = (".alarms", len)
answer_fields = [{"type": "number", "label": "Number of alarms"}]
# → Goes via Path A; get_expected_response is auto-derived from get_answer()
```
**Multi-field task**: when `get_answer()` returns a dict, the default `get_expected_response` unpacks the values in dict order. The order of `answer_fields` **must match the dict key order**:
```python
class QueryFirstEvent(AnswerTask):
def get_answer(self, input):
return {"title": "周会", "time": "14:30"} # dict with 2 keys
# → get_expected_response auto-returns ["周会", "14:30"]
answer_fields = [
{"type": "text", "label": "日程标题", "hint": "e.g. 周会"}, # ← title
{"type": "text", "label": "开始时间", "hint": "e.g. 14:30", "matcher": "time"}, # ← time
]
```
**Field type that varies with a parameter**: when the task's `field` parameter has enum values that mix text and numeric fields, you can't declare both types in a class-level `answer_fields`. Make `answer_fields` a `@property` and return dynamically based on `self.p.field`:
```python
class CheckSearchNoteField(AnswerTask):
parameters = {
"field": {
"type": "enum",
"values": {
"标题": "title", # text
"点赞数": "likes", # number
"收藏数": "collections", # number
"作者名": "authorName", # text
},
},
}
_NUMERIC_FIELDS = {"likes", "collections"}
@property
def answer_fields(self): # type: ignore[override]
field_val = getattr(self.p, "field", None)
# Reverse-look-up the enum to get the human label; avoid showing internal keys ("likes" etc.)
label = next(
(k for k, v in self.parameters["field"]["values"].items() if v == field_val),
field_val or "",
)
t = "number" if field_val in self._NUMERIC_FIELDS else "text"
return [{"type": t, "label": label}]
```
**Notes**:
- A `@property` is fully compatible with framework access points like `getattr(task, "answer_fields", None)` — no framework changes required.
- The `label` must come from a reverse lookup on the enum `values`, not a `"{field}"` template — the `{field}` placeholder resolves to the internal value (e.g., `"likes"`), not the human label.
- `getattr(self.p, "field", None)` guards against `AttributeError` when `self.p` isn't initialized yet.
- mypy/pyright will warn about a `ClassVar` overridden by `@property` — silence with `# type: ignore[override]`.
**When you must override `get_expected_response`**: when `get_answer()` returns an `re.Pattern` (fuzzy matching), grounded mode requires exact values:
```python
class CompareCityTemp(AnswerTask):
answer_fields = [{"type": "choice", "label": "Hotter city",
"options": ["{city1}", "{city2}", "Tied"]}]
def get_answer(self, input):
# text mode: may return re.Pattern
return re.compile(r"一样|相同|差不多")
def get_expected_response(self, input):
# grounded mode: must return an exact value
return ["Tied"]
```
**Another override case**: `get_answer()` returns a `dict` but `answer_fields` has only one field. The default implementation unpacks dict values into multiple expected values, so **the field count and value count won't match**:
```python
class CheckDetailCard(AnswerTask):
answer_fields = [{"type": "text", "label": "Result"}] # 1 field
def get_answer(self, input):
return {"dir": "东风", "scale": "3"}
# ⚠️ default get_expected_response returns ["东风", "3"] — 2 values!
def get_expected_response(self, input):
answer = self.get_answer(input)
if isinstance(answer, dict):
return [f"{answer['dir']}{answer['scale']}级"] # → ["东风3级"]
return [str(answer)]
```
**Repeatable variant**: when `get_answer()` returns a dynamically-sized dict (each entry is one list element) and `answer_fields` is a single `repeatable` field, override as well. `get_expected_response` must return `[[v1, v2, ...]]` — the outer list has 1 element (matching the 1 field), and the inner list contains the repeatable values:
```python
class ReadTodoText(AnswerTask):
answer_fields = [{"type": "text", "label": "Todos", "repeatable": True, "compare": "set"}]
def get_answer(self, input):
# text mode: dict for build_answer_checks to match slot-by-slot via containment
notes = Notes(input.apps["notes"])
return {f"todo_{i+1}": str(t.get("text") or "") for i, t in enumerate(notes.incomplete_todos)}
# ⚠️ default get_expected_response returns ["buy groceries", "do laundry", ...] — N values, but only 1 field!
def get_expected_response(self, input):
# grounded mode: 1 field + repeatable → outer 1 element, inner the full list
notes = Notes(input.apps["notes"])
return [[str(t.get("text") or "") for t in notes.incomplete_todos]]
```
### 3.2 Task with a custom `check_goals` (AnswerTask or BaseTask)
Just add `answer_fields` (with `hint`); the existing `check_goals` reads the injected value via `input.answer`:
```python
class RailwayDestWeatherQuery(AnswerTask):
answer_fields = [
{"type": "text", "label": "Conditions", "hint": "e.g. Sunny"},
{"type": "text", "label": "High temp", "hint": "e.g. 23°"},
{"type": "text", "label": "Low temp", "hint": "e.g. 15°"},
]
def check_goals(self, input):
# In grounded mode, input.answer = "Sunny, 23°, 15°" (joined AnswerSheet values)
# In text mode, it's the Agent's natural-language answer
answer_text = str(input.answer or "")
...
```
> **Key point**: matching logic inside `check_goals` must accept the AnswerSheet's compact joined format.
> Use `hint` to guide the Agent toward a format `check_goals` can match.
### 3.3 Hybrid task (operate + query)
Same as 3.2. `check_goals` checks both state changes and the answer:
```python
class FavVideoAndCountTask(BaseTask):
answer_fields = [{"type": "number", "label": "Items in favorites"}]
def check_goals(self, input):
app = Bilibili(input.apps["bilibili"])
return [
app.check_favored(title), # state check
*build_answer_checks(count, input.answer), # answer check
]
```
### 3.4 Custom question text
`answer_fields` can also take a dict form with a `question` field, which sets the **question text displayed at the top of the AnswerSheet**:
```python
class MakeupDayReminder(BaseTask):
templates = ["帮我看看{holiday}需不需要补班"]
answer_fields = {
"question": "今年{holiday}需要补班吗?",
"fields": [
{"type": "choice", "label": "Need to work that day?",
"options": ["Yes, work that day", "No"]},
],
}
```
**Two pieces of text, two roles**:
| Text | Source | Audience | Purpose |
|---|---|---|---|
| Agent instruction | `task.description` (= rendered templates + AnswerSheet suffix) | Agent | Tells the Agent what to do |
| AnswerSheet question | `question` (dict form) or falls back to `task.description` | AnswerSheet UI | What the Agent sees when opening the AnswerSheet |
**Use case**: when the task description contains operational instructions ("do XX for me… and tell me…") and is not a great prompt for the AnswerSheet form, use `question` to give a cleaner restatement.
**Resolution logic** (`Controller.setup`):
```python
question = task._resolve_answer_question() or task.description
# dict form has question → use it (supports {param} templates)
# list form has no question → fall back to task.description
```
---
## 4. `answer_fields` reference
### 4.1 Field types
| `type` | Description | UI control | Default matcher |
|---|---|---|---|
| `text` | Free text | Text input | `exact` |
| `number` | Number | Numeric input | `number` |
| `choice` | Single choice (needs `options`) | Selection list | `exact` |
**How to pick a field type**:
| Answer shape | Type | Example |
|---|---|---|
| One value from a finite set | `choice` | Hotter city (A/B/tied), yes/no |
| Pure number | `number` | Alarm count, contact count, price |
| Open text | `text` | Event title, weather description, address |
| Unknown count (0..N items) | `text` + `repeatable` | List all matching cities |
**Selection priority**: `choice` > `number` > `text`. Prefer `choice` over `text` when possible — picking from buttons is less error-prone than typing, and evaluation is more precise (no format ambiguity).
**When to use `repeatable`**: when the answer is a list of variable length (e.g., "which days will it rain", "temperature for each city"), declare `text`/`number` + `repeatable: true`; the Agent can add entries one by one. Combine with `compare: "set"` to ignore order.
### 4.2 Optional attributes
**UI rendering attributes** (used by both paths; determine how the AnswerSheet renders):
| Attribute | Type | Description |
|---|---|---|
| `label` | `str` | Field label (supports `{param}` templates) |
| `hint` | `str` | Placeholder (e.g. `"e.g. 14:30"`) |
| `options` | `list[str]` | Options for `choice` (supports `{param}`) |
| `repeatable` | `bool` | Allow multiple values |
**Task-level attributes** (declared on the Task class, not per-field):
| Attribute | Type | Description |
|---|---|---|
| `answer_hint` | `str` or `None` | Global hint at the top of the AnswerSheet (shown below the question) |
**Evaluation attributes** (Path A `build_grounded_checks` only; ignored on Path B):
| Attribute | Type | Description |
|---|---|---|
| `matcher` | `str` | Matcher override: `exact` / `number` / `date` / `time` / `duration` |
| `compare` | `str` | Comparison mode for repeatable fields: `sequence` (default) / `set` (order-insensitive) |
### 4.3 Matchers in detail
All matchers are dispatched through `_match_grounded_field()` (`common_tasks.py`). When `matcher` is unspecified, the framework picks a default based on `type` (`text`/`choice``exact`, `number``number`).
| Matcher | Function / logic | Typical use |
|---|---|---|
| `exact` | `normalize_text(actual) == normalize_text(expected)` | City names, book titles, choice options |
| `number` | `math.isclose(float(actual), float(expected))` | Counts |
| `date` | `date_match_labels()` (`utils.py`) | Dates |
| `time` | `match_time()` (`common_tasks.py`) | Time of day |
| `duration` | `match_duration()` (`common_tasks.py`) | Durations |
**`exact`** — precise match (default)
Implemented inline in `_match_grounded_field`. Both sides are `strip()`+`normalize_text()` (Chinese-numeral → Arabic-numeral normalization) then compared with `==`.
```
expected = "北京" actual = " 北京 " → normalize → "北京" == "北京" → ✓
expected = "北京" actual = "上海" → ✗
```
**`number`** — numeric match
Implemented inline. `math.isclose(float(actual), float(expected), rel_tol=1e-6, abs_tol=1e-9)`.
```
expected = 3 actual = "3" → float("3") == 3.0 → ✓
expected = 3 actual = "3个" → float("3个") → ValueError → ✗
```
> ⚠️ `actual` is passed to `float()` directly; numbers are not extracted from surrounding text. The Agent must fill in a bare number.
**`date`** — date-equivalence match
Calls `bench_env.task.utils.date_match_labels(expected, os_state)` to generate every valid representation (`"4月6日"` / `"4月6号"` / `"04-06"` / `"周一"` / `"明天"` etc.); `normalize_text(actual)` hitting any of them passes. Relative dates are computed off the OS simulated time (`os_state`).
```
expected = "2026-04-06" actual = "4月6日" → ✓
expected = "2026-04-06" actual = "明天" → ✓ (when sim time is 4/5)
expected = "2026-04-06" actual = "周一" → ✓ (when 4/6 actually is Monday)
```
**`time`** — time-of-day match (±5 min tolerance)
Calls `match_time(expected, actual, tolerance_minutes=5)` (`common_tasks.py`). Normalizes to `(hour, minute)`; supports `HH:MM`, `H点M分`, `上午/下午/凌晨` prefixes; handles midnight wraparound.
```
expected = "14:30" actual = "下午2点30分" → (14,30) vs (14,30) → ✓
expected = "09:54" actual = "9:58" → diff=4min ≤ 5 → ✓
expected = "09:54" actual = "10:02" → diff=8min > 5 → ✗
```
**`duration`** — duration match
Calls `match_duration(expected, actual)` (`common_tasks.py`). Normalizes to total minutes; supports `X小时Y分`, `Z分钟`, `H:MM`, etc.
```
expected = "1小时30分" actual = "90分钟" → 90 == 90 → ✓
expected = "0小时59分" actual = "59分" → 59 == 59 → ✓
expected = "2小时15分" actual = "2:15" → 135 == 135 → ✓
```
### 4.4 Hint convention
Each field type has a **default placeholder** (used when `hint` is not specified):
| `type` | Default placeholder |
|---|---|
| `text` | "Please enter" |
| `number` | "Please enter a number" |
| `choice` | (none — buttons are self-explanatory) |
**No custom hint needed**:
- `number` — default placeholder already requires a numeric input
- `choice` — option buttons themselves are the hint; the Agent just taps
**Custom hint needed**: for `text` when the answer format is non-obvious; the `hint` should provide a **typical example value** to guide formatting:
```python
{"hint": "e.g. Sunny"} # weather
{"hint": "e.g. 23°"} # temperature
{"hint": "e.g. 14:30"} # time
{"hint": "e.g. 233 元"} # price
{"hint": "e.g. 三体"} # book title
```
**Cross-validating hint against the check logic**:
A hint is more than a UI prompt — it's the meeting point of **task semantics** and **evaluation logic (the check)**. When writing a hint, review both sides instead of blindly matching the check's format:
1. **Start from task semantics**: what's being asked? What format would the user (the Agent) naturally use?
2. **Look at the check**: what does `get_answer()` / `get_expected_response()` return? How does `matcher` or `check_goals` match?
3. **Cross-validate**: are they consistent? Can the hint's example value both be matched by the check and read naturally as an answer?
**If you find a mismatch, treat it as a potential check bug — don't silently bend the hint to the check's format.** Common mismatches:
| Scenario | Task semantics | Check actually does | Issue |
|---|---|---|---|
| "Meeting start time" | May include date + time | `get_answer` returns just `"14:30"` | Did the check drop the date? Depends on context — if multiple meetings of the same name happen on the same day, time alone isn't unique |
| "Price" | Includes a unit like "233 元" | `exact` matcher compares strings | Does `get_answer` return `"233"` or `"233 元"`? Mismatch causes false negatives |
| "Total duration" | Natural answer "1 hour 30 min" | `number` matcher expects a bare number | Pin the Agent to `90` by writing the label "Total duration (minutes)", or use the `duration` matcher to accept natural phrasing? |
> ⚠️ **Principle: the hint reflects the task's natural format; the check must accept that format.** If the check can't match the task's natural format, the check is the bug — not the hint. Report and fix the check logic when you find these.
---
## 5. Path B caveats
### 5.1 Hydrate joining format
Multiple AnswerSheet field values are joined with `", "` and injected as `input.answer`:
```
field 0 = "Sunny", field 1 = "23°", field 2 = "15°"
→ input.answer = "Sunny, 23°, 15°"
```
### 5.2 False-positive risk with same-typed multi-field values
When multiple fields share the same value type (e.g., two temperature fields), substring matching in `check_goals` may incorrectly accept a swapped fill.
**Example**: expected high=23° / low=15°; Agent fills high=15° / low=23°.
- `input.answer = "15°, 23°"`
- `has_close_number("15°, 23°", 23)` → matches 23 → True (false positive!)
**Recommended**: for tasks with this risk, don't write a custom `check_goals` — let the framework go via Path A for per-field precise matching.
**Not recommended**: if you must keep a custom `check_goals` (e.g., to also check state changes), read the AnswerSheet's structured values in grounded mode:
```python
def check_goals(self, input):
checks = [self._check_state(input)] # state check
sheet = input.apps.get("answer_sheet", {})
answers = sheet.get("answers", {})
if answers:
# grounded mode: read by field index
high = answers.get("0", "")
low = answers.get("1", "")
checks.append({"field": "high", "passed": has_close_number(high, expected_high), ...})
checks.append({"field": "low", "passed": has_close_number(low, expected_low), ...})
else:
# text mode: match from the joined text
checks.extend(self._match_from_text(input.answer))
return checks
```
> ⚠️ This couples `check_goals` to the evaluation mode and raises maintenance cost. Only use when unavoidable.
### 5.3 `check_goals` must accept both modes
`check_goals` runs in both text mode and grounded mode (Path B). Make sure the matching logic accepts:
- **text mode**: `input.answer` is the Agent's natural-language reply
- **grounded mode**: `input.answer` is the `", "`-joined AnswerSheet values
Loose matching such as `xxx in answer_text` or `has_close_number(answer_text, expected)` usually accepts both.
### 5.4 The Submit button is a toggle
The AnswerSheet's Submit button is a submit/unsubmit toggle. The Agent can submit, edit, and submit again. Evaluation reads the **final state**:
- `submitted = True` + correct answer → pass
- `submitted = False` (even with a correct answer) → fail (both Path A and Path B check `submitted`)
> The Agent must leave the AnswerSheet in a submitted state. If it edits after submitting and forgets to re-submit, evaluation will fail.
---
## 6. Framework guarantees
### 6.1 Side-effect isolation
`BaseTask.always_ignore` includes `apps.answer_sheet` globally, so AnswerSheet state changes (field edits, submission, etc.) **are not counted as unexpected side effects**. Tasks need not declare AnswerSheet paths in `expected_changes`.
### 6.2 Automatic step budget bump
In grounded mode, `RunnerConfig.get_max_steps()` automatically adds 15 steps for tasks with `answer_fields` (for opening the AnswerSheet, filling, and submitting). If a task defines its own `max_steps`, that value should describe only the task interaction budget; do not include the extra AnswerSheet budget manually.
### 6.3 Auto-appended instruction
`Controller.setup` automatically appends an AnswerSheet hint to `task.task_name`:
```python
task.task_name = task.description + " then open the AnswerSheet app, enter the answer, and submit"
```
So the Agent sees something like: `"check whether tomorrow needs make-up work then open the AnswerSheet app, enter the answer, and submit"`. **Task templates need not mention the AnswerSheet** — the framework appends it.
---
## 7. Developer checklist
- [ ] **Declare `answer_fields`**: did the query / hybrid task declare it? Types and labels accurate?
- [ ] **Hint cross-validation**: for `text` fields, did you provide a format example? Does the hint example satisfy both the **task's natural semantics** and what the **check actually accepts**? If they conflict, file it as a check bug.
- [ ] **Matcher override**: `time` for time fields, `date` for dates, `duration` for durations.
- [ ] **`get_expected_response`**: overridden when `get_answer()` returns `re.Pattern`? Overridden (with merging) when it returns a dict but `answer_fields` has fewer entries than dict keys?
- [ ] **`check_goals` mode compatibility**: for tasks with a custom `check_goals`, does the matching logic accept the AnswerSheet's compact joined format?
- [ ] **Multi-field risk**: do multiple same-typed fields risk cross-contamination? If so, are you reading `answer_sheet.answers` structured values directly?