Spaces:
Running
Running
File size: 8,566 Bytes
e2329f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | """
EDD harness — Day 3
Runs each of the three Day-3 agents' JSON eval cases and checks:
1. The output validates against its Pydantic schema (this alone catches
most silent multi-agent failures, per the design doc's Section 1).
2. A handful of soft field assertions per case (contains / not_null /
equals / min / max / length checks).
Each agent is run IN ISOLATION (not through the full pipeline) with
session state seeded directly. This matters for url_reputation cases
specifically: running the full pipeline for every case would burn IPQS's
1,000/month and VirusTotal's 4/min budget on cases that don't even test
reputation logic. Only the url_reputation suite touches those APIs.
This is intentionally lightweight — full trajectory-mode scoring (exact
tool-call sequence via ADK's eval module) is a Day 6 evaluation-layer
task, not a Day 3 blocker. This harness exists to catch schema mismatches
and obviously wrong extractions before they compound into Day 4's Risk
Synthesis Agent.
Usage (from repo root):
python eval/run_eval.py intake
python eval/run_eval.py linguistic
python eval/run_eval.py url_reputation # touches IPQS/VirusTotal
python eval/run_eval.py all
"""
import asyncio
import json
import sys
import uuid
from pathlib import Path
# Same fix as scripts/smoke_test_day3.py: running `python eval/run_eval.py`
# puts eval/ on sys.path, not the repo root, so `agents` isn't importable
# without this.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Same fix as scripts/smoke_test_day3.py — must run before any agent
# touches the Gemini client.
from dotenv import load_dotenv
load_dotenv()
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from agents.intake_agent import intake_agent
from agents.linguistic_agent import linguistic_agent
from agents.schemas import IntakeResult, LinguisticSignal, ReputationSignalList
from agents.url_reputation_agent import url_reputation_agent
APP_NAME = "scamsentry-eval"
CASES_DIR = Path(__file__).resolve().parent / "cases"
AGENTS = {
"intake": intake_agent,
"url_reputation": url_reputation_agent,
"linguistic": linguistic_agent,
}
SCHEMAS = {
"intake": IntakeResult,
"url_reputation": ReputationSignalList,
"linguistic": LinguisticSignal,
}
STATE_KEYS = {
"intake": "intake_result",
"url_reputation": "reputation_signals",
"linguistic": "linguistic_signal",
}
def check_soft_assertions(output: dict, expected: dict) -> list[str]:
"""Returns a list of human-readable failure messages (empty = pass).
NOTE ON ORDERING (fixed 2026-07-03): suffixes are checked longest/most
specific first. "_len_min" and "_len_equals" MUST be checked before
"_min"/"_max"/"_equals" respectively, because e.g. "signals_len_min"
also ends with "_min" — str.endswith() doesn't know one suffix is
"more specific" than another, so the shorter/more general check will
silently steal the match if it comes first in the chain, computing
the wrong field name (e.g. "signals_len" instead of "signals") and
producing a false failure against a field that never existed. This
caused every url_reputation case to fail with "expected 'signals_len'
... got None" even when the underlying agent output was fully valid
and had already passed schema validation above this check.
"""
failures = []
for key, expectation in expected.items():
if key.endswith("_contains"):
field = key[: -len("_contains")]
actual_list = output.get(field) or []
for item in expectation:
if not any(str(item).lower() in str(v).lower() for v in actual_list):
failures.append(
f"expected {field!r} to contain {item!r}, got {actual_list!r}"
)
elif key.endswith("_not_null"):
field = key[: -len("_not_null")]
if expectation and output.get(field) is None:
failures.append(f"expected {field!r} to be non-null, got null")
elif key.endswith("_len_min"):
field = key[: -len("_len_min")]
if len(output.get(field) or []) < expectation:
failures.append(
f"expected len({field!r}) >= {expectation!r}, "
f"got {len(output.get(field) or [])}"
)
elif key.endswith("_len_equals"):
field = key[: -len("_len_equals")]
if len(output.get(field) or []) != expectation:
failures.append(
f"expected len({field!r}) == {expectation!r}, "
f"got {len(output.get(field) or [])}"
)
elif key.endswith("_equals"):
field = key[: -len("_equals")]
if output.get(field) != expectation:
failures.append(
f"expected {field!r} == {expectation!r}, got {output.get(field)!r}"
)
elif key.endswith("_min"):
field = key[: -len("_min")]
if (output.get(field) or 0) < expectation:
failures.append(
f"expected {field!r} >= {expectation!r}, got {output.get(field)!r}"
)
elif key.endswith("_max"):
field = key[: -len("_max")]
if (output.get(field) or 0) > expectation:
failures.append(
f"expected {field!r} <= {expectation!r}, got {output.get(field)!r}"
)
return failures
async def run_agent_isolated(agent_key: str, seed_state: dict, user_text: str) -> dict:
"""Runs a single agent with pre-seeded session state (bypasses the
rest of the pipeline)."""
agent = AGENTS[agent_key]
session_service = InMemorySessionService()
user_id, session_id = "eval_user", str(uuid.uuid4())
await session_service.create_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id, state=seed_state
)
runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service)
content = types.Content(role="user", parts=[types.Part(text=user_text)])
async for _ in runner.run_async(
user_id=user_id, session_id=session_id, new_message=content
):
pass
final_session = await session_service.get_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
raw = final_session.state.get(STATE_KEYS[agent_key])
if raw is None:
return {"_missing_output": True}
try:
return json.loads(raw) if isinstance(raw, str) else raw
except json.JSONDecodeError:
return {"_parse_error": True, "_raw": str(raw)}
async def run_case(agent_key: str, case: dict) -> tuple[bool, str]:
inp = case["input"]
if agent_key == "intake":
seed_state, user_text = {}, inp["text"]
else:
# url_reputation and linguistic both read {intake_result} from
# state — seed it directly rather than re-running Intake.
seed_state = {"intake_result": json.dumps(inp["intake_result"])}
user_text = "continue"
output = await run_agent_isolated(agent_key, seed_state, user_text)
if output.get("_missing_output"):
return False, "agent produced no output for this state key"
if output.get("_parse_error"):
return False, f"output was not valid JSON: {output['_raw'][:200]}"
try:
SCHEMAS[agent_key].model_validate(output)
except Exception as e:
return False, f"schema validation failed: {e}"
failures = check_soft_assertions(output, case.get("expected_output", {}))
if failures:
return False, "; ".join(failures)
return True, "ok"
async def run_suite(agent_key: str) -> None:
case_file = CASES_DIR / f"{agent_key}_cases.json"
if not case_file.exists():
print(f"No case file found at {case_file}")
return
cases = json.loads(case_file.read_text())
passed = 0
for case in cases:
ok, message = await run_case(agent_key, case)
status = "PASS" if ok else "FAIL"
print(f"[{status}] {case['case_id']}: {message}")
if ok:
passed += 1
print(f"\n{agent_key}: {passed}/{len(cases)} passed")
async def main():
target = sys.argv[1] if len(sys.argv) > 1 else "all"
keys = list(AGENTS.keys()) if target == "all" else [target]
for key in keys:
print(f"\n=== {key} ===")
await run_suite(key)
if __name__ == "__main__":
asyncio.run(main()) |