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,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Experiment 9-5 with a real model, localhost HTTP, and Chromium."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from openai import OpenAI
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from learning_agent import KnowledgeBase
|
||||
from learning_agent.replay import WorkflowReplayer
|
||||
from learning_agent.workflow import (
|
||||
ActionType,
|
||||
PredicateType,
|
||||
StatePredicate,
|
||||
Workflow,
|
||||
WorkflowStep,
|
||||
)
|
||||
from local_mail_sandbox import mail_sandbox
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
FIRST = {"recipient": "test@example.com", "subject": "测试邮件", "content": "第一次探索正文"}
|
||||
TRANSFER = {"recipient": "second@example.org", "subject": "季度报告", "content": "完全不同的回放正文"}
|
||||
ALLOWED_PLAN = [
|
||||
("input_text", "#recipient", "recipient"),
|
||||
("input_text", "#subject", "subject"),
|
||||
("input_text", "#content", "content"),
|
||||
("click", "#send", None),
|
||||
]
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return _jsonable(value.model_dump(mode="json", exclude_none=True))
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def _provider_client(provider: str) -> tuple[OpenAI, dict[str, Any]]:
|
||||
if provider == "openrouter":
|
||||
key = os.getenv("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("OPENROUTER_API_KEY is required for --provider openrouter")
|
||||
endpoint = "https://openrouter.ai/api/v1"
|
||||
return OpenAI(api_key=key, base_url=endpoint), {
|
||||
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "OPENROUTER_API_KEY"
|
||||
}
|
||||
if provider == "ark":
|
||||
key = os.getenv("ARK_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("ARK_API_KEY is required for --provider ark")
|
||||
endpoint = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
return OpenAI(api_key=key, base_url=endpoint), {
|
||||
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "ARK_API_KEY"
|
||||
}
|
||||
if provider == "moonshot":
|
||||
key = os.getenv("MOONSHOT_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("MOONSHOT_API_KEY is required for --provider moonshot")
|
||||
endpoint = "https://api.moonshot.cn/v1"
|
||||
return OpenAI(api_key=key, base_url=endpoint), {
|
||||
"provider": provider, "endpoint": endpoint + "/chat/completions", "credential_env": "MOONSHOT_API_KEY"
|
||||
}
|
||||
key = os.getenv("OPENAI_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("OPENAI_API_KEY is required for --provider openai")
|
||||
return OpenAI(api_key=key), {
|
||||
"provider": provider, "endpoint": "https://api.openai.com/v1/chat/completions", "credential_env": "OPENAI_API_KEY"
|
||||
}
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict[str, Any]:
|
||||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.I)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
match = re.search(r"\{.*\}", cleaned, re.S)
|
||||
if not match:
|
||||
raise
|
||||
return json.loads(match.group(0))
|
||||
|
||||
|
||||
def _usage(response_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
usage = response_payload.get("usage") or {}
|
||||
cost = usage.get("cost")
|
||||
return {
|
||||
"prompt_tokens": int(usage.get("prompt_tokens") or 0),
|
||||
"completion_tokens": int(usage.get("completion_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
"provider_reported_cost_usd": float(cost) if cost is not None else None,
|
||||
"cost_qualification": (
|
||||
"provider-native usage.cost" if cost is not None
|
||||
else "provider did not expose monetary cost; no price was guessed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sum_usage(receipts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
prompt = completion = total = 0
|
||||
cost = 0.0
|
||||
cost_count = 0
|
||||
for receipt in receipts:
|
||||
usage = receipt["response"].get("usage") or {}
|
||||
prompt += int(usage.get("prompt_tokens") or 0)
|
||||
completion += int(usage.get("completion_tokens") or 0)
|
||||
total += int(usage.get("total_tokens") or 0)
|
||||
if usage.get("cost") is not None:
|
||||
cost += float(usage["cost"])
|
||||
cost_count += 1
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": total or prompt + completion,
|
||||
"provider_reported_cost_usd": round(cost, 9) if cost_count else None,
|
||||
"provider_reported_cost_observations": cost_count,
|
||||
"cost_qualification": (
|
||||
"provider-native usage.cost summed across calls"
|
||||
if cost_count else "provider did not expose monetary cost; no price was guessed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def model_explore(
|
||||
base_url: str, client: OpenAI, backend: dict[str, Any], model: str,
|
||||
seed: int, screenshot_path: Path,
|
||||
):
|
||||
"""Run a real observe-decide-act loop, one provider call per browser step."""
|
||||
started = time.perf_counter()
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
browser_version = browser.version
|
||||
page = await browser.new_page()
|
||||
await page.goto(base_url + "/app", wait_until="networkidle")
|
||||
receipts = []
|
||||
captured = []
|
||||
for step_index, expected in enumerate(ALLOWED_PLAN):
|
||||
controls = await page.locator("input, textarea, button").evaluate_all("""els => els.map(e => {
|
||||
const parts=[]; let n=e;
|
||||
while(n && n.nodeType===1){ let i=1, s=n.previousElementSibling; while(s){if(s.tagName===n.tagName)i++;s=s.previousElementSibling} parts.unshift(n.tagName.toLowerCase()+'['+i+']');n=n.parentElement; }
|
||||
return {tag:e.tagName.toLowerCase(),id:e.id,name:e.name,type:e.type||null,
|
||||
role:e.getAttribute('role'),aria_label:e.getAttribute('aria-label'),
|
||||
data_testid:e.getAttribute('data-testid'),text:e.innerText||'',
|
||||
label:e.labels?.[0]?.innerText||'',value:e.value||'',xpath:'/'+parts.join('/')};
|
||||
})""")
|
||||
status = {
|
||||
"url": page.url,
|
||||
"status_text": await page.locator("#status").inner_text(),
|
||||
"persisted_count": await page.evaluate("window.__agentState.persistedCount"),
|
||||
}
|
||||
prompt = f"""You are the stepwise browser Agent for a local disposable mail sandbox.
|
||||
Choose exactly the next safe action, not a multi-action plan. Return JSON only as
|
||||
{{"action":"input_text|click","selector":"#id","value_key":"recipient|subject|content|null"}}.
|
||||
Goal: send one message using the supplied parameter keys. Fill recipient, subject and content before clicking send.
|
||||
Task parameters: {json.dumps(FIRST, ensure_ascii=False)}
|
||||
Prior actions: {json.dumps(captured, ensure_ascii=False)}
|
||||
Current page state: {json.dumps(status, ensure_ascii=False)}
|
||||
Current controls: {json.dumps(controls, ensure_ascii=False)}
|
||||
"""
|
||||
request_payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0,
|
||||
"seed": seed + step_index,
|
||||
"max_tokens": 300,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
api_started = time.perf_counter()
|
||||
response = await asyncio.to_thread(client.chat.completions.create, **request_payload)
|
||||
raw_response = _jsonable(response)
|
||||
decision = _extract_json(response.choices[0].message.content or "")
|
||||
normalized = (decision.get("action"), decision.get("selector"), decision.get("value_key"))
|
||||
if normalized != expected:
|
||||
raise RuntimeError(f"model returned unsafe or out-of-order step {step_index + 1}: {normalized!r}; expected {expected!r}")
|
||||
action, selector, value_key = normalized
|
||||
control = next(item for item in controls if f"#{item['id']}" == selector)
|
||||
before = {
|
||||
"url": page.url,
|
||||
"visible": await page.locator(selector).is_visible(),
|
||||
"page_status": status,
|
||||
}
|
||||
if action == "input_text":
|
||||
await page.locator(selector).fill(FIRST[str(value_key)])
|
||||
else:
|
||||
await page.locator(selector).click()
|
||||
await page.wait_for_function("window.__agentState.persistedCount === 1")
|
||||
captured.append({
|
||||
"action": action, "selector": selector, "value_key": value_key,
|
||||
"action_parameters": ({"text_template": "{" + str(value_key) + "}"} if value_key else {}),
|
||||
"before": before,
|
||||
"after": {
|
||||
"url": page.url,
|
||||
"value": await page.locator(selector).input_value() if action == "input_text" else None,
|
||||
"status_text": await page.locator("#status").inner_text(),
|
||||
},
|
||||
"locator_evidence": {
|
||||
"xpath": control["xpath"], "css": selector, "id": control["id"],
|
||||
"name": control["name"], "type": control["type"], "role": control["role"],
|
||||
"aria-label": control["aria_label"], "data-testid": control["data_testid"],
|
||||
"captured_url": page.url,
|
||||
},
|
||||
})
|
||||
receipts.append({
|
||||
"kind": "browser_agent_step",
|
||||
"step": step_index + 1,
|
||||
"backend": {**backend, "credential_value_recorded": False},
|
||||
"request": request_payload,
|
||||
"response": raw_response,
|
||||
"elapsed_seconds": round(time.perf_counter() - api_started, 6),
|
||||
})
|
||||
final_text = await page.locator("#sent-list").inner_text()
|
||||
await page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
await browser.close()
|
||||
return {
|
||||
"success": all(value in final_text for value in FIRST.values()),
|
||||
"captured_actions": captured,
|
||||
"llm_calls": len(receipts),
|
||||
"elapsed_seconds": round(time.perf_counter() - started, 6),
|
||||
"browser": {"engine": "playwright-chromium", "version": browser_version, "headless": True},
|
||||
"api_receipts": receipts,
|
||||
"usage": _sum_usage(receipts),
|
||||
"screenshot": screenshot_path.name,
|
||||
}
|
||||
|
||||
|
||||
def compile_workflow(base_url: str, captured: list[dict[str, Any]]) -> Workflow:
|
||||
workflow = Workflow(
|
||||
workflow_id="local-mail-send-v1",
|
||||
intent="send message",
|
||||
initial_url=base_url + "/app",
|
||||
example_parameters=dict(FIRST),
|
||||
description="Model-explored localhost mail workflow",
|
||||
)
|
||||
for item in captured:
|
||||
selector = item["selector"]
|
||||
locator = item.get("locator_evidence") or {}
|
||||
attributes = {
|
||||
key: str(value) for key, value in locator.items()
|
||||
if key in {"id", "name", "type", "role", "aria-label", "data-testid"} and value is not None
|
||||
}
|
||||
if item["action"] == "input_text":
|
||||
key = item["value_key"]
|
||||
workflow.add_step(WorkflowStep(
|
||||
action_type=ActionType.INPUT_TEXT,
|
||||
xpath=locator.get("xpath"),
|
||||
css_selector=selector,
|
||||
element_attributes=attributes,
|
||||
parameters={"text": "{" + key + "}", "clear_existing": True},
|
||||
description=f"fill {key}",
|
||||
preconditions=[StatePredicate(
|
||||
PredicateType.ELEMENT_VISIBLE, True, selector=selector,
|
||||
description=f"{key} field is visible",
|
||||
)],
|
||||
postconditions=[StatePredicate(
|
||||
PredicateType.ELEMENT_VALUE_EQUALS, "{" + key + "}", selector=selector,
|
||||
description=f"{key} field contains this run's value",
|
||||
)],
|
||||
))
|
||||
else:
|
||||
workflow.add_step(WorkflowStep(
|
||||
action_type=ActionType.CLICK,
|
||||
xpath=locator.get("xpath"),
|
||||
css_selector=selector,
|
||||
element_attributes=attributes,
|
||||
description="submit message",
|
||||
preconditions=[StatePredicate(
|
||||
PredicateType.ELEMENT_VISIBLE, True, selector=selector,
|
||||
description="send button is visible before the side effect",
|
||||
)],
|
||||
postconditions=[
|
||||
StatePredicate(
|
||||
PredicateType.ELEMENT_TEXT_CONTAINS, "Message sent", selector="#status",
|
||||
description="server-confirmed send status is visible",
|
||||
),
|
||||
StatePredicate(
|
||||
PredicateType.PAGE_STATE_EQUALS, True, state_key="sent",
|
||||
description="page state confirms persistence",
|
||||
),
|
||||
],
|
||||
))
|
||||
if workflow.steps:
|
||||
workflow.steps[0].preconditions.insert(0, StatePredicate(
|
||||
PredicateType.URL_CONTAINS, "/app",
|
||||
description="workflow is on the captured application route",
|
||||
))
|
||||
for key in ("recipient", "subject", "content"):
|
||||
workflow.final_predicates.append(StatePredicate(
|
||||
PredicateType.ELEMENT_TEXT_CONTAINS, "{" + key + "}", selector="#sent-list",
|
||||
description=f"sent-list contains current {key}",
|
||||
))
|
||||
return workflow
|
||||
|
||||
|
||||
async def replay(workflow: Workflow, parameters: dict[str, str], *, validate_state: bool = True):
|
||||
runner = WorkflowReplayer(headless=True)
|
||||
await runner.setup()
|
||||
try:
|
||||
return await runner.replay_workflow(workflow, parameters=parameters, validate_state=validate_state)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
async def campaign(args: argparse.Namespace) -> dict[str, Any]:
|
||||
client, backend = _provider_client(args.provider)
|
||||
stamp = datetime.now(timezone.utc).strftime("real_%Y%m%dT%H%M%SZ")
|
||||
output_dir = args.output_dir or ROOT / "validation" / stamp
|
||||
output_dir.mkdir(parents=True, exist_ok=False)
|
||||
store_dir = output_dir / "workflow_store"
|
||||
|
||||
with mail_sandbox() as (base_url, state):
|
||||
state.reset(version=1)
|
||||
screenshot_path = output_dir / "exploration-final.png"
|
||||
exploration = await model_explore(base_url, client, backend, args.model, args.seed, screenshot_path)
|
||||
workflow = compile_workflow(base_url, exploration["captured_actions"])
|
||||
store = KnowledgeBase(str(store_dir))
|
||||
store.save_candidate(workflow)
|
||||
candidate_file = store_dir / f"candidate_{workflow.workflow_id}.json"
|
||||
candidate_before_reset = candidate_file.exists() and not store.workflows
|
||||
candidate_snapshot = output_dir / "candidate_snapshot.json"
|
||||
shutil.copyfile(candidate_file, candidate_snapshot)
|
||||
|
||||
# An independent HTTP reset is the validation_reset boundary.
|
||||
_post(base_url + "/api/reset", {"version": 1, "fault": "normal"})
|
||||
validation = await replay(workflow, FIRST)
|
||||
if validation["success"]:
|
||||
workflow.mark_validated()
|
||||
store.publish_validated(workflow)
|
||||
|
||||
# A workflow with no reset callback remains a candidate and cannot be retrieved.
|
||||
no_reset = compile_workflow(base_url, exploration["captured_actions"])
|
||||
no_reset.workflow_id = "no-reset-negative-control"
|
||||
store.save_candidate(no_reset)
|
||||
no_reset_stayed_candidate = no_reset.workflow_id not in store.workflows
|
||||
|
||||
# Match and replay different literals without another model call.
|
||||
_post(base_url + "/api/reset", {"version": 1, "fault": "normal"})
|
||||
match = store.find_workflow_for_task(
|
||||
'send message to second@example.org with subject "季度报告" and content "完全不同的回放正文"'
|
||||
)
|
||||
transfer = await replay(match.workflow, TRANSFER) if match else {"success": False, "actions_executed": []}
|
||||
transfer_state = state.snapshot()
|
||||
transfer_exact = transfer_state["messages"] == [TRANSFER]
|
||||
|
||||
false_success_cases = []
|
||||
for name, parameters, fault in (
|
||||
("blank_content", {**TRANSFER, "content": ""}, "normal"),
|
||||
("dropped_persistence", dict(TRANSFER), "drop_persistence"),
|
||||
):
|
||||
_post(base_url + "/api/reset", {"version": 1, "fault": fault})
|
||||
naive = await replay(workflow, parameters, validate_state=False)
|
||||
naive_persisted = bool(state.snapshot()["messages"])
|
||||
_post(base_url + "/api/reset", {"version": 1, "fault": fault})
|
||||
validated = await replay(workflow, parameters, validate_state=True)
|
||||
validated_persisted = bool(state.snapshot()["messages"])
|
||||
false_success_cases.append({
|
||||
"case": name,
|
||||
"naive_reported_success": naive["success"],
|
||||
"naive_actual_success": naive_persisted,
|
||||
"validated_reported_success": validated["success"],
|
||||
"validated_actual_success": validated_persisted,
|
||||
"validated_failure": validated.get("failed_predicate") or validated.get("errors"),
|
||||
})
|
||||
|
||||
# UI version 2 changes the button locator. The precondition must stop
|
||||
# before any POST /api/send occurs, then the store archives the version.
|
||||
_post(base_url + "/api/reset", {"version": 2, "fault": "normal"})
|
||||
before_change_events = len([e for e in state.snapshot()["events"] if e["event"] == "send_request"])
|
||||
changed = await replay(workflow, TRANSFER)
|
||||
after_change_events = len([e for e in state.snapshot()["events"] if e["event"] == "send_request"])
|
||||
if not changed["success"]:
|
||||
store.invalidate_workflow(workflow.workflow_id, changed.get("failed_predicate") or str(changed["errors"]))
|
||||
invalid_file = store_dir / f"invalid_{workflow.workflow_id}.json"
|
||||
post_invalidation_match = store.find_workflow_for_task("send message to audit@example.org")
|
||||
|
||||
naive_false_rate = sum(
|
||||
row["naive_reported_success"] and not row["naive_actual_success"] for row in false_success_cases
|
||||
) / len(false_success_cases)
|
||||
validated_false_rate = sum(
|
||||
row["validated_reported_success"] and not row["validated_actual_success"] for row in false_success_cases
|
||||
) / len(false_success_cases)
|
||||
metrics = {
|
||||
"exploration_seconds": exploration["elapsed_seconds"],
|
||||
"validation_replay_seconds": validation["execution_time"],
|
||||
"parameterized_replay_seconds": transfer.get("execution_time"),
|
||||
"exploration_llm_calls": exploration["llm_calls"],
|
||||
"parameterized_replay_llm_calls": 0,
|
||||
"exploration_success_rate": float(exploration["success"]),
|
||||
"replay_success_rate": float(bool(transfer["success"])),
|
||||
"workflow_match_rate": float(match is not None),
|
||||
"naive_false_success_rate": naive_false_rate,
|
||||
"state_validated_false_success_rate": validated_false_rate,
|
||||
"page_change_detection_rate": float(not changed["success"]),
|
||||
"fallback_relearn_count": int(changed["fallback_required"]),
|
||||
"speedup_exploration_over_parameterized_replay": (
|
||||
exploration["elapsed_seconds"] / transfer.get("execution_time")
|
||||
if transfer.get("execution_time") else None
|
||||
),
|
||||
}
|
||||
gates = {
|
||||
"real_stepwise_model_exploration_succeeded": exploration["success"] and exploration["llm_calls"] == len(ALLOWED_PLAN),
|
||||
"capture_has_actions_parameters_url_and_locator_evidence": all(
|
||||
item.get("before", {}).get("url")
|
||||
and item.get("locator_evidence", {}).get("xpath")
|
||||
and item.get("locator_evidence", {}).get("css")
|
||||
and item.get("locator_evidence", {}).get("id")
|
||||
and "action_parameters" in item
|
||||
for item in exploration["captured_actions"]
|
||||
),
|
||||
"workflow_has_pre_post_and_final_state_checks": (
|
||||
all(step.preconditions and step.postconditions for step in workflow.steps)
|
||||
and bool(workflow.final_predicates)
|
||||
),
|
||||
"first_success_created_candidate": candidate_before_reset,
|
||||
"independent_reset_replay_validated": validation["success"] and workflow.validated_at is not None,
|
||||
"missing_reset_never_published": no_reset_stayed_candidate,
|
||||
"validated_workflow_was_matchable": match is not None,
|
||||
"parameterized_replay_used_no_llm": transfer["success"] and transfer_exact,
|
||||
"training_literals_not_reused": transfer_exact and FIRST not in transfer_state["messages"],
|
||||
"naive_baseline_exposes_false_success": naive_false_rate == 1.0,
|
||||
"state_validation_rejects_false_success": validated_false_rate == 0.0 and all(
|
||||
not row["validated_reported_success"] for row in false_success_cases
|
||||
),
|
||||
"page_change_stopped_before_side_effect": (
|
||||
not changed["success"] and changed["fallback_required"]
|
||||
and before_change_events == after_change_events
|
||||
),
|
||||
"invalid_version_removed_from_retrieval": (
|
||||
invalid_file.exists() and workflow.workflow_id not in store.workflows
|
||||
and post_invalidation_match is None
|
||||
),
|
||||
}
|
||||
result_claims = {
|
||||
"validated_workflow_completed_parameterized_transfer": bool(transfer["success"] and transfer_exact),
|
||||
"replay_eliminated_stepwise_llm_calls": exploration["llm_calls"] > 0 and metrics["parameterized_replay_llm_calls"] == 0,
|
||||
"state_validation_reduced_false_success_rate": validated_false_rate < naive_false_rate,
|
||||
"page_change_was_detected_before_side_effect": gates["page_change_stopped_before_side_effect"],
|
||||
"replay_was_faster_than_exploration": bool(
|
||||
transfer.get("execution_time") and exploration["elapsed_seconds"] > transfer["execution_time"]
|
||||
),
|
||||
}
|
||||
report = {
|
||||
"schema_version": 2,
|
||||
"experiment": "9-5",
|
||||
"canonical_source": "book/chapter9.md#实验-9-5-从浏览器轨迹生成可验证工作流",
|
||||
"executed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"execution_mode": "real_model_plus_local_http_plus_real_playwright_chromium",
|
||||
"model": args.model,
|
||||
"seed": args.seed,
|
||||
"backend": {**backend, "credential_value_recorded": False},
|
||||
"credential_value_recorded": False,
|
||||
"host": {"python": platform.python_version(), "platform": platform.platform()},
|
||||
"lifecycle": {
|
||||
"exploration": exploration,
|
||||
"candidate_file": str(candidate_file.relative_to(output_dir)),
|
||||
"validation_reset_count": state.reset_count,
|
||||
"validation_replay": validation,
|
||||
"transfer_replay": transfer,
|
||||
"page_change_replay": changed,
|
||||
"invalid_file": str(invalid_file.relative_to(output_dir)),
|
||||
},
|
||||
"false_success_comparison": false_success_cases,
|
||||
"metrics": metrics,
|
||||
"cost": exploration["usage"],
|
||||
"sandbox": {
|
||||
"url_origin": base_url,
|
||||
"external_side_effects": False,
|
||||
"final_state": state.snapshot(),
|
||||
},
|
||||
"gates": gates,
|
||||
"acceptance": {
|
||||
"gates": [{"name": name, "passed": passed} for name, passed in gates.items()],
|
||||
"execution_accepted": all(gates.values()),
|
||||
"result_claims": result_claims,
|
||||
"all_manuscript_result_claims_observed": all(result_claims.values()),
|
||||
},
|
||||
"accepted": all(gates.values()),
|
||||
}
|
||||
|
||||
report_path = output_dir / "evidence.json"
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
report["artifact_hashes"] = {
|
||||
"candidate_snapshot_sha256": _sha(candidate_snapshot),
|
||||
"invalid_sha256": _sha(invalid_file),
|
||||
"exploration_screenshot_sha256": _sha(screenshot_path),
|
||||
}
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
latest = ROOT / "validation" / "latest.json"
|
||||
latest.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(report_path, latest)
|
||||
print(json.dumps({
|
||||
"evidence": str(report_path.relative_to(ROOT)),
|
||||
"evidence_sha256": _sha(report_path),
|
||||
"accepted": report["accepted"],
|
||||
"metrics": report["metrics"],
|
||||
"cost": report["cost"],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--provider", choices=("openrouter", "openai", "ark", "moonshot"), default="ark")
|
||||
parser.add_argument("--model", default="doubao-seed-1-6-flash-250615")
|
||||
parser.add_argument("--seed", type=int, default=8401)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
report = asyncio.run(campaign(args))
|
||||
return 0 if report["accepted"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user