HARvestGym / server /judge.py
kdcyberdude's picture
Upload folder using huggingface_hub
e6ce96e verified
Raw
History Blame
24.1 kB
"""
HARvestGym Judge — deterministic programmatic graders for all 7 task templates.
Each grader inspects the episode trajectory and/or probes the live application
to compute a task score in [0.0, 1.0], then maps it to the reward range.
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from typing import Any
try:
import requests as _requests
_REQUESTS_AVAILABLE = True
except ImportError:
_REQUESTS_AVAILABLE = False
from .episode import Episode, EpisodeResult, Step, Task
# ---------------------------------------------------------------------------
# Reward tables (score → reward)
# ---------------------------------------------------------------------------
REWARD_TABLES = {
1: {1.0: 2.0, 0.3: 0.5, 0.0: -1.5},
2: {1.0: 2.0, 0.5: 0.5, 0.0: -1.5},
3: {1.0: 3.5, 0.2: 0.5, 0.15: 0.3, 0.0: -1.5},
4: {1.0: 3.5, 0.3: 0.8, 0.0: -1.5},
5: {1.0: 5.0, 0.5: 1.5, 0.3: 0.8, 0.0: -1.5},
6: {1.0: 5.0, 0.6: 2.5, 0.3: 0.8, 0.1: 0.3, 0.0: -1.5},
7: {1.0: 5.0, 0.7: 2.0, 0.2: 0.5, 0.0: -1.5},
}
AUTH_BONUS = 0.3 # added when auth was successfully obtained even if task fails
def _score_to_reward(score: float, template_id: int) -> float:
"""Map a [0,1] task score to a reward using the template's reward table."""
table = REWARD_TABLES.get(template_id, {1.0: 2.0, 0.0: -1.5})
# Find closest matching threshold
thresholds = sorted(table.keys(), reverse=True)
for threshold in thresholds:
if score >= threshold:
return table[threshold]
return table.get(0.0, -1.5)
# ---------------------------------------------------------------------------
# HTTP probe helper
# ---------------------------------------------------------------------------
def _judge_probe(path: str, base_url: str, headers: dict | None = None,
timeout: int = 10) -> Any:
"""Issue an HTTP GET from the judge (not the model) to verify live state."""
if not _REQUESTS_AVAILABLE:
return None
url = base_url.rstrip("/") + path
try:
resp = _requests.get(url, headers=headers or {}, timeout=timeout, verify=False)
result = type("ProbeResult", (), {
"status_code": resp.status_code,
"body": None,
})()
try:
result.body = resp.json()
except Exception:
result.body = resp.text
return result
except Exception as e:
print(f"[judge] probe failed {url}: {e}", flush=True)
return None
def _judge_post_probe(path: str, base_url: str, data: dict | None = None,
headers: dict | None = None, timeout: int = 10) -> Any:
"""Issue an HTTP POST probe from the judge."""
if not _REQUESTS_AVAILABLE:
return None
url = base_url.rstrip("/") + path
try:
resp = _requests.post(url, json=data, headers=headers or {}, timeout=timeout, verify=False)
result = type("ProbeResult", (), {"status_code": resp.status_code, "body": None})()
try:
result.body = resp.json()
except Exception:
result.body = resp.text
return result
except Exception as e:
print(f"[judge] post probe failed {url}: {e}", flush=True)
return None
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _fuzzy_match(a: str, b: str) -> bool:
"""Case-insensitive substring match in both directions."""
a, b = a.lower().strip(), b.lower().strip()
return a in b or b in a or a == b
def _path_matches(path: str, pattern: str) -> bool:
"""Check if a (normalized) path matches a pattern."""
return pattern.lower() in path.lower() or path.lower() in pattern.lower()
def _extract_field(obj: Any, field_path: str) -> Any:
"""Extract a nested field via dot notation: 'items.0.sku'."""
parts = field_path.split(".")
current = obj
for part in parts:
if current is None:
return None
if isinstance(current, dict):
current = current.get(part)
elif isinstance(current, list):
try:
current = current[int(part)]
except (IndexError, ValueError):
return None
else:
return None
return current
def _get_curl_steps(episode: Episode):
"""Return only steps that have curl_parsed."""
return [s for s in episode.steps if s.curl_parsed is not None]
# ---------------------------------------------------------------------------
# Template graders
# ---------------------------------------------------------------------------
def grade_template_1(episode: Episode, task: Task) -> float:
"""Easy — Shopping: List products in category {category_name}"""
category_name = task.params.get("category_name", "")
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
body = cp.response_body
if isinstance(body, dict) and "items" in body:
items = body["items"]
if len(items) > 0:
# Check if any item mentions the category
for item in items:
if _item_matches_category(item, category_name):
return 1.0
# Items returned but can't verify category — partial
return 0.3
# Also check if it's a raw list
if isinstance(body, list) and len(body) > 0:
return 0.3
return 0.0
def _item_matches_category(item: dict, category_name: str) -> bool:
"""Check if an item is in the given category."""
# Check category_links field
for link in item.get("category_links", []):
# We trust the response at face value; category name match is partial anyway
pass
# Check extension_attributes
ext = item.get("extension_attributes", {})
category_links = ext.get("category_links", [])
if category_links:
return True # has category links; assume matches
# Fallback: just having items is enough for category listing
return True
def grade_template_2(episode: Episode, task: Task) -> float:
"""Easy — Wikipedia: Retrieve article for {title}"""
title = task.params.get("title", "")
title_slug = title.lower().replace(" ", "_")
title_lower = title.lower()
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
url_lower = cp.url.lower()
# Direct article fetch
if title_slug in url_lower or title_lower.replace(" ", "_") in url_lower:
return 1.0
if "wiki/" + title_slug in url_lower:
return 1.0
# Search result found the article
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
body_str = str(cp.response_body).lower()
if title_lower in body_str and "wiki" in cp.url.lower():
return 0.5
return 0.0
def _extract_cart_id(episode: Episode) -> str | None:
"""Extract guest cart ID from episode trajectory."""
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
# POST /rest/V1/guest-carts returns bare string cart ID
if "guest-carts" in cp.path and cp.method == "POST":
body = cp.response_body
if isinstance(body, str) and len(body) > 5:
return body.strip('"').strip()
return None
def grade_template_3(episode: Episode, task: Task) -> float:
"""Medium — Shopping: Add {product_name} to a guest cart"""
product_name = task.params.get("product_name", "")
sku = task.params.get("sku")
# Primary: check if add-to-cart responded with item_id
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
body = cp.response_body
if isinstance(body, dict) and "item_id" in body:
# Verify the sku if we have it
if sku and body.get("sku") == sku:
return 1.0
if _fuzzy_match(str(body.get("name", "")), product_name):
return 1.0
if body.get("item_id"):
return 1.0
# Try live probe
cart_id = _extract_cart_id(episode)
if cart_id:
probe = _judge_probe(f"/rest/V1/guest-carts/{cart_id}", task.base_url)
if probe and probe.status_code == 200:
items = probe.body.get("items", []) if isinstance(probe.body, dict) else []
for item in items:
if sku and item.get("sku") == sku:
return 1.0
if _fuzzy_match(str(item.get("name", "")), product_name):
return 1.0
if len(items) == 0:
return 0.2 # cart created, item not added
# Partial: cart was created
if cart_id:
return 0.2
# Partial: attempted cart creation
if any("guest-carts" in (s.curl_parsed.path or "") and
s.curl_parsed.method == "POST"
for s in _get_curl_steps(episode)):
return 0.15
return 0.0
def _check_forum_auth(episode: Episode) -> bool:
"""Check if forum authentication was obtained."""
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.method == "POST" and "/login" in cp.path:
if cp.status_code in (200, 302):
return True
return False
def _response_contains_posts(body: Any) -> bool:
"""Check if a response body contains forum posts."""
if isinstance(body, list) and len(body) > 0:
return True
if isinstance(body, dict):
# Could be JSON with posts array or HTML
for key in ("posts", "items", "data", "results"):
if key in body and isinstance(body[key], list) and len(body[key]) > 0:
return True
# Postmill returns HTML — check for common post indicators
body_str = str(body).lower()
if "post" in body_str or "submission" in body_str:
return True
if isinstance(body, str) and len(body) > 100:
return True # HTML response from forum
return False
def grade_template_4(episode: Episode, task: Task) -> float:
"""Medium — Forum: Retrieve posts in {forum_category} (authed)"""
forum_category = task.params.get("forum_category", "")
score = 0.0
auth_obtained = _check_forum_auth(episode)
if auth_obtained:
score += 0.3
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
url = cp.url
body = cp.response_body
if f"/f/{forum_category.lower()}" in url.lower():
if _response_contains_posts(body):
return 1.0
# Also accept generic post listing with the category in URL
if forum_category.lower() in url.lower() and _response_contains_posts(body):
return 1.0
return score
def _check_csrf_in_trajectory(episode: Episode) -> bool:
"""Check that a POST body contained a _csrf_token field."""
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.method == "POST":
body_str = str(cp.body or "").lower()
if "_csrf_token" in body_str and len(body_str) > 20:
return True
return False
def _judge_probe_forum_category(category: str, base_url: str) -> list:
"""Probe the forum to get posts in a category."""
probe = _judge_probe(f"/f/{category}.json", base_url)
if probe and probe.status_code == 200:
body = probe.body
if isinstance(body, dict):
return body.get("posts", body.get("submissions", []))
if isinstance(body, list):
return body
return []
def grade_template_5(episode: Episode, task: Task) -> float:
"""Hard — Forum: Create a post titled {title} in {category}"""
title = task.params.get("title", "")
category = task.params.get("category", "")
auth_ok = _check_forum_auth(episode)
csrf_used = _check_csrf_in_trajectory(episode)
# Check if POST to submit returned success
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.method == "POST" and cp.status_code in (200, 201, 302):
if "submit" in cp.path or "post" in cp.path.lower():
# Post creation succeeded
body_str = str(cp.response_body or "").lower()
if title.lower() in body_str or "redirect" in str(cp.response_headers).lower():
return 1.0
if cp.status_code in (201, 302):
return 1.0
# Try judge probe
posts = _judge_probe_forum_category(category, task.base_url)
for post in posts:
post_title = post.get("title", post.get("name", ""))
if _fuzzy_match(post_title, title):
return 1.0
if auth_ok and csrf_used:
return 0.5
if auth_ok:
return 0.3
return 0.0
def _checkout_stages_completed(episode: Episode, sku: str | None) -> int:
"""Count checkout stages completed successfully."""
stages = 0
paths_hit = {
s.curl_parsed.path
for s in _get_curl_steps(episode)
if s.curl_parsed.status_code == 200
}
if any("guest-carts" in p and "{" not in p for p in paths_hit):
stages += 1
if any("guest-carts" in p and "items" in p for p in paths_hit):
stages += 1
if any("guest-carts" in p and ("shipping" in p or "email" in p) for p in paths_hit):
stages += 1
if any("guest-carts" in p and ("payment" in p or "order" in p) for p in paths_hit):
stages += 1
return stages
def grade_template_6(episode: Episode, task: Task) -> float:
"""Hard — Shopping: Guest checkout for {product_name}"""
sku = task.params.get("sku")
# Check for order ID
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200:
body = cp.response_body
if isinstance(body, int) and body > 0:
return 1.0
if isinstance(body, str):
try:
v = int(body.strip('"').strip())
if v > 0:
return 1.0
except (ValueError, AttributeError):
pass
if isinstance(body, dict) and body.get("order_id"):
return 1.0
stages = _checkout_stages_completed(episode, sku)
if stages >= 4:
return 0.6
if stages >= 2:
return 0.3
if stages >= 1:
return 0.1
return 0.0
def _extract_admin_token(episode: Episode) -> str | None:
"""Find admin bearer token from episode trajectory."""
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200 and "integration/admin/token" in cp.path:
body = cp.response_body
if isinstance(body, str) and len(body) > 10:
return body.strip('"').strip()
return None
def _attempted_product_creation(episode: Episode, sku: str) -> bool:
"""Check if the model attempted to create a product with this SKU."""
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.method == "POST" and "products" in cp.path:
body_str = str(cp.body or "").lower()
if sku.lower() in body_str:
return True
return False
def grade_template_7(episode: Episode, task: Task) -> float:
"""Hard — Shopping Admin: Create product with SKU {sku}, price {price}"""
sku = task.params.get("sku", "")
price = float(task.params.get("price", 0))
admin_token = _extract_admin_token(episode)
if not admin_token:
return 0.0
# Check if product creation returned success
for step in _get_curl_steps(episode):
cp = step.curl_parsed
if cp.status_code == 200 and cp.method == "POST" and "products" in cp.path:
body = cp.response_body
if isinstance(body, dict) and body.get("id"):
actual_price = float(body.get("price", -1))
price_ok = abs(actual_price - price) < 0.01
return 1.0 if price_ok else 0.7
# Judge probe
probe = _judge_probe(
f"/rest/V1/products/{sku}",
task.base_url,
headers={"Authorization": f"Bearer {admin_token}"}
)
if probe and probe.status_code == 200 and isinstance(probe.body, dict):
actual_price = float(probe.body.get("price", -1))
price_ok = abs(actual_price - price) < 0.01
return 1.0 if price_ok else 0.7
if _attempted_product_creation(episode, sku):
return 0.2
return 0.0
# ---------------------------------------------------------------------------
# Parameter sourcing verification
# ---------------------------------------------------------------------------
def _load_catalog(app: str) -> list[dict]:
"""Load the ground truth catalog for an app."""
catalog_path = Path(__file__).parent.parent.parent / "catalogs" / f"{app}.json"
if not catalog_path.exists():
return []
try:
with open(catalog_path) as f:
data = json.load(f)
return data if isinstance(data, list) else data.get("endpoints", [])
except Exception:
return []
def _find_catalog_entry(path: str, method: str, catalog: list[dict]) -> dict | None:
method = method.upper()
for entry in catalog:
cat_method = entry.get("method", "GET").upper()
cat_path = entry.get("path", "")
# Pattern match: {id} in catalog matches any segment
if cat_method == method and _path_pattern_match(path, cat_path):
return entry
return None
def _path_pattern_match(actual_path: str, catalog_path: str) -> bool:
"""Match actual path against catalog pattern with {id} wildcards."""
# Convert catalog pattern to regex
pattern = re.escape(catalog_path)
pattern = pattern.replace(r"\{", "{").replace(r"\}", "}")
pattern = re.sub(r"\{[^}]+\}", "[^/]+", pattern)
pattern = f"^{pattern}$"
return bool(re.match(pattern, actual_path, re.IGNORECASE))
def verify_parameter_sourcing(episode: Episode, task: Task) -> float:
"""Analyze parameter sourcing across episode trajectory. Returns [0, 1] score."""
catalog = _load_catalog(task.app)
if not catalog:
return 0.5 # neutral if no catalog
correct = 0
total = 0
steps = _get_curl_steps(episode)
for step in steps:
cp = step.curl_parsed
catalog_entry = _find_catalog_entry(cp.path, cp.method, catalog)
if not catalog_entry:
continue
path_params = catalog_entry.get("path_params", {})
body_params = catalog_entry.get("body_params", {})
for param_name, param_meta in path_params.items():
total += 1
value = _extract_path_param_value(cp.url, param_name)
if value and _param_sourced_correctly(value, param_meta, episode, step):
correct += 1
for param_name, param_meta in body_params.items():
total += 1
value = _extract_body_param_value(cp.body, param_name)
if value and _param_sourced_correctly(value, param_meta, episode, step):
correct += 1
if total == 0:
return 0.5
return correct / total
def _extract_path_param_value(url: str, param_name: str) -> str | None:
"""Best-effort path param extraction."""
# Just extract last non-empty path segment as a value
from urllib.parse import urlparse
path = urlparse(url).path
segments = [s for s in path.split("/") if s]
if segments:
return segments[-1]
return None
def _extract_body_param_value(body: Any, param_name: str) -> Any:
"""Extract a named param from request body."""
if body is None:
return None
if isinstance(body, dict):
if param_name in body:
return body[param_name]
# Search nested
for v in body.values():
if isinstance(v, dict):
result = _extract_body_param_value(v, param_name)
if result is not None:
return result
if isinstance(body, str):
# Form-encoded: key=value&...
for pair in body.split("&"):
if "=" in pair:
k, _, v = pair.partition("=")
if k.strip() == param_name:
return v.strip()
return None
def _param_sourced_correctly(value: Any, param_meta: dict,
episode: Episode, step: Step) -> bool:
source = param_meta.get("source", "")
value_str = str(value)
if source == "TASK_SPEC":
return value_str in episode.task.description
elif source == "PREV_CALL":
from_endpoint = param_meta.get("from_endpoint", "")
from_field = param_meta.get("from_field", "")
for prior_step in episode.steps:
if prior_step.step_num >= step.step_num:
break
if prior_step.curl_parsed:
ps = prior_step.curl_parsed
if _path_matches(ps.path, from_endpoint):
extracted = _extract_field(ps.response_body, from_field)
if str(extracted) == value_str:
return True
return False
elif source == "AUTH_FLOW":
return value_str in str(episode.session_state.values())
elif source == "STATIC":
expected = str(param_meta.get("value", ""))
return value_str == expected
elif source == "DERIVED":
from_param = param_meta.get("from_param", "")
# Simplified: check if it appeared anywhere in session state
return value_str in str(episode.session_state.values())
return True # unknown source type — don't penalize
# ---------------------------------------------------------------------------
# Main judge entry point
# ---------------------------------------------------------------------------
_GRADERS = {
1: grade_template_1,
2: grade_template_2,
3: grade_template_3,
4: grade_template_4,
5: grade_template_5,
6: grade_template_6,
7: grade_template_7,
}
def evaluate(episode: Episode) -> EpisodeResult:
"""
Evaluate a completed episode and return reward + diagnostics.
Args:
episode: Completed episode with all steps recorded.
Returns:
EpisodeResult with task_score, parameter_sourcing_score, reward, details.
"""
task = episode.task
template_id = task.template_id
grader = _GRADERS.get(template_id)
if grader is None:
return EpisodeResult(
task_score=0.0,
parameter_sourcing_score=0.0,
auth_obtained=False,
reward=-1.5,
details={"error": f"Unknown template_id: {template_id}"},
)
task_score = grader(episode, task)
param_score = verify_parameter_sourcing(episode, task)
auth_obtained = _check_forum_auth(episode) or bool(_extract_admin_token(episode))
# Compute reward
reward = _score_to_reward(task_score, template_id)
# Bonus for auth obtained even on task failure
if task_score < 0.5 and auth_obtained:
reward = max(reward, AUTH_BONUS)
return EpisodeResult(
task_score=task_score,
parameter_sourcing_score=param_score,
auth_obtained=auth_obtained,
reward=reward,
details={
"template_id": template_id,
"difficulty": task.difficulty,
"task_score": task_score,
"param_score": param_score,
"terminated_by": episode.terminated_by,
"total_steps": episode.total_steps,
},
)