diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e708b..2ea7aea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,19 @@ jobs: python-version: "3.12" - run: pip install build - run: python -m build + + # Offline half of the eval suite: the eval files match the shipped parser, so a + # recipe or task can never name a command the CLI cannot run. The live half + # needs MIMIT and Overpass and runs in the weekly canary instead. + agent-evals: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . + - run: scripts/run-agent-evals.sh + env: + PITSTOP_EVAL_BIN: pitstop + PITSTOP_EVAL_OFFLINE: "1" diff --git a/.github/workflows/upstream-smoke.yml b/.github/workflows/upstream-smoke.yml index 2a5e494..e4bec13 100644 --- a/.github/workflows/upstream-smoke.yml +++ b/.github/workflows/upstream-smoke.yml @@ -278,3 +278,8 @@ jobs: fi exit "$rc" + + - name: Run live agent smoke evals + run: scripts/run-agent-evals.sh + env: + PITSTOP_EVAL_BIN: pitstop diff --git a/README.md b/README.md index ad0849f..e0cf698 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ Example client config entry: { "mcpServers": { "pitstop": { "command": "pitstop-mcp" } } } ``` +Machine-readable command recipes, with the caveats that belong with each answer, are in [evals/agent/recipes.json](evals/agent/recipes.json); the agent skill bundle is in [skills/pitstop/SKILL.md](skills/pitstop/SKILL.md). `scripts/run-agent-evals.sh` checks those recipes against the live CLI, and [evals/agent/README.md](evals/agent/README.md) explains how a scored round is recorded. + ## Development ```bash diff --git a/evals/agent/README.md b/evals/agent/README.md new file mode 100644 index 0000000..28418d7 --- /dev/null +++ b/evals/agent/README.md @@ -0,0 +1,67 @@ +# Agent Evals + +These evals test whether an agent can answer real fuel and EV-charging questions with `pitstop` without claiming more than the open data supports. + +The goal is not to make `pitstop` answer broad natural-language questions itself. The goal is to verify that an agent can: + +- pick the vocabulary the dataset uses instead of guessing it, +- choose stable CLI filters and parse the JSON envelope, +- read the per-price quality fields (`median_basis`, `regional_median`, `outlier`) before standing behind a number, +- treat an upstream failure as unknown rather than as an absence, +- state the source, the extraction date, and the Italy-only scope. + +## Files + +- `tasks.json` - real user-style prompts, expected command paths, scoring criteria, and common failure modes. +- `recipes.json` - machine-readable command recipes, parse targets, and caveats for common agent workflows. +- `results/` - dated reports from scored manual eval rounds. +- `../../scripts/run-agent-evals.sh` - live contract checks for the CLI surfaces the eval tasks use. + +## Run The Smoke Evals + +From the repository root: + +```bash +scripts/run-agent-evals.sh +``` + +The runner calls the public MIMIT and Overpass endpoints. It requires `python3` and network access, and nothing else — the JSON work that the sibling `odh` runner gives to `jq` is done with the standard library, because pitstop's runtime has no third-party dependencies either. + +To test an installed CLI instead of the source tree: + +```bash +PITSTOP_EVAL_BIN=pitstop scripts/run-agent-evals.sh +``` + +MIMIT failures are hard failures. Overpass is a free community endpoint whose transient 5xx is normal operation, so it is retried and then downgraded to a warning — the same policy as `.github/workflows/upstream-smoke.yml`. pitstop's *handling* of that failure is asserted on every run either way. + +## Manual Agent Eval Protocol + +Use each `prompt` in `tasks.json` as a fresh agent task. The agent may use the `pitstop` CLI and, where a task calls for it, an operator's official page; it should not scrape unrelated websites by default. + +Use `recipes.json` as a stable command-path library. Recipes are not final answers; they tell an agent which commands to run, which fields to parse, and which caveats must be reflected in the answer. + +Score each task as: + +- `pass` - uses the expected command path, handles the caveats, and gives a source-aware answer. +- `partial` - reaches useful data but misses a caveat, uses a less direct command, or overstates certainty. +- `fail` - guesses fuel names or prices, presents daily data as live, reads an upstream failure as an absence, or invents a number the data does not carry. + +For every failure, decide whether the fix belongs in: + +- documentation or skill guidance, +- an eval task clarification, +- a narrow CLI feature, +- or the agent's own reasoning layer. + +Keep the CLI clean: add command surface only after repeated eval failures show the same missing mechanical data-access step. + +## Recording A Round + +Write `results/.md` with: + +- **Setup** - CLI version and commit, how many agent attempts, what each agent was given (typically the installed CLI, `skills/pitstop/SKILL.md`, and `recipes.json`, with `tasks.json` and the repo source withheld), and the live upstream conditions during the round. +- **Scores** - one table row per task id with `pass` / `partial` / `fail`, then the totals. +- **Notes Per Task** - one line per task saying what the agent did and why it scored that way. +- **Failure Analysis And Fix Categories** - each observed issue with its fix category and whether it recurred. +- **Data Findings Worth Keeping** - upstream behaviour the round exposed that outlives it. diff --git a/evals/agent/recipes.json b/evals/agent/recipes.json new file mode 100644 index 0000000..18b9599 --- /dev/null +++ b/evals/agent/recipes.json @@ -0,0 +1,164 @@ +{ + "schema_version": 1, + "purpose": "Machine-readable command recipes for agents using pitstop. These are stable starting paths, not natural-language answers.", + "recipes": [ + { + "id": "discovery-first-fuel-names", + "question_pattern": "Pick the fuel name the dataset actually uses before filtering on it.", + "commands": [ + "pitstop fuels --json", + "pitstop stations --comune ROMA --fuel Gasolio --limit 5 --json" + ], + "parse": [ + "fuels[].fuel", + "fuels[].count", + "price_extraction_date" + ], + "caveats": [ + "`--fuel` is a substring match over MIMIT's own fuel names, and it cuts both ways: `Diesel` matches only premium blends such as Blue Diesel while ordinary diesel is `Gasolio`, and `Gasolio` in turn also returns Gasolio Premium, Gasolio speciale, Gasolio Alpino and Gasolio artico. Read `stations[].prices[].fuel` before comparing prices, or a premium blend will be ranked against ordinary diesel.", + "Petrol is `Benzina`, LPG is `GPL`, CNG is `Metano`; every other name is a brand blend that can appear or vanish between extractions.", + "Run this before guessing a fuel name — a wrong guess returns a plausible but unrelated price." + ] + }, + { + "id": "cheapest-fuel-near-place", + "question_pattern": "Cheapest fuel within driving distance of a coordinate.", + "commands": [ + "pitstop stations --near 41.9028,12.4964 --radius 5 --fuel Gasolio --cheapest --min-price 1.2 --fresh-within-days 30 --limit 5 --json", + "pitstop stations --near 41.9028,12.4964 --radius 5 --fuel Gasolio --cheapest --min-price 1.2 --fresh-within-days 30 --drop-outliers --limit 5 --json" + ], + "parse": [ + "count", + "stations[].distance_km", + "stations[].prices[].price", + "stations[].prices[].updated", + "stations[].prices[].outlier", + "stations[].navigation_url", + "quality", + "price_extraction_date" + ], + "caveats": [ + "Prices are daily, not real-time: they are what operators reported as of ~08:00 the day before `price_extraction_date`. Say so instead of saying \"right now\".", + "The cheapest rows are the likeliest misreports. Check `outlier` (emitted only when true) or rerun with `--drop-outliers` before sending anyone to a station.", + "`--min-price 1.2` drops the 1.000 placeholder some operators report; leave it off for GPL, whose real prices sit below that floor.", + "`--fresh-within-days` drops prices whose `updated` timestamp is older than N days; without it a years-old row can win the ranking.", + "A station flagged `coordinate_suspect` sits more than 30 km from where its declared comune's other stations are (their median coordinate, or the ISTAT reference point when the comune has fewer than three stations), or outside Italy altogether. Its `distance_km` is unreliable even when its price is not.", + "Italy only." + ] + }, + { + "id": "cheapest-fuel-in-comune", + "question_pattern": "Cheapest fuel in a named town, including English, German, or French names.", + "commands": [ + "pitstop stations --comune Milan --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json", + "pitstop stations --comune Milan --fuel \"Benzina,Gasolio\" --limit 10 --json" + ], + "parse": [ + "query.comune", + "count", + "stations[].prices[].fuel", + "stations[].prices[].price", + "stations[].prices[].self_service" + ], + "caveats": [ + "`--comune` resolves common English/German/French city names to MIMIT's Italian name; read `query.comune` to see what it resolved to and name that town in the answer.", + "`--comune` is an exact match on the municipality, not a radius: a cheaper station in the next municipality will not appear. Use `--near` for a travel-distance question.", + "`--cheapest` with a comma-separated `--fuel` list ranks each station by whichever of those fuels is cheapest there; pass a single fuel when the ranking has to mean one fuel.", + "Each station can report a self-service and a served price for the same fuel; `--self` or `--served` picks one.", + "Prices are daily, not real-time. Italy only." + ] + }, + { + "id": "price-sanity-check", + "question_pattern": "Decide whether a suspiciously cheap price can be stood behind.", + "commands": [ + "pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --limit 10 --json", + "pitstop stats --fuel Gasolio --json" + ], + "parse": [ + "quality.screened", + "quality.unscreened", + "quality.outliers", + "stations[].prices[].median_basis", + "stations[].prices[].regional_median", + "stations[].prices[].deviation_pct", + "stations[].prices[].outlier", + "stats.Gasolio.provinces.RM.median" + ], + "caveats": [ + "`screened` means the price was compared with the median of its (fuel, provincia) bucket; `unscreened` means that bucket held fewer than 15 samples, so no outlier check ran and the price is returned exactly as reported.", + "An unscreened price carries no `regional_median` and no `deviation_pct`. Absence of `outlier` on it means unchecked, not clean.", + "`outlier` is emitted only when true — read it with `.get(\"outlier\")`, never `[\"outlier\"]`.", + "`stats` medians come from the same daily file, so they are a market baseline, not an independent confirmation.", + "A price can be stale rather than wrong: read each price's `updated` before calling it an error." + ] + }, + { + "id": "compare-provinces", + "question_pattern": "Compare fuel price levels between provinces or against the national level.", + "commands": [ + "pitstop stats --fuel Gasolio --json", + "pitstop stations --provincia BZ --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json" + ], + "parse": [ + "stats.Gasolio.national.median", + "stats.Gasolio.national.count", + "stats.Gasolio.provinces.BZ.median", + "stats.Gasolio.provinces.BZ.count", + "price_extraction_date" + ], + "caveats": [ + "Compare provinces with `stats` medians, not with a handful of `stations` rows: five stations are not a province.", + "Each province's `count` is the number of price rows behind its median; a thin province moves noticeably day to day.", + "`stats` buckets by the 2-letter province code and skips registry rows whose province field is malformed, so its counts are slightly below the raw feed.", + "Both figures come from one daily extraction, so a comparison describes that day, not a trend." + ] + }, + { + "id": "fast-chargers-near-place", + "question_pattern": "Find fast or ultra-fast EV chargers near a place.", + "commands": [ + "pitstop chargers --near 46.498,11.354 --radius 10 --fast --limit 10 --json", + "pitstop chargers --near 46.498,11.354 --radius 10 --ultra-fast --limit 10 --json", + "pitstop chargers --comune Bozen --radius 10 --fast --limit 10 --json" + ], + "parse": [ + "count", + "error", + "stations[].max_power_kw", + "stations[].sockets[]", + "stations[].operator", + "stations[].access", + "stations[].distance_km", + "stations[].navigation_url" + ], + "caveats": [ + "Source is OpenStreetMap via Overpass. Coverage, power, and plug fields are what mappers entered; present them as unverified and name OSM.", + "`--fast` is `--min-power 50` over the station's highest tagged socket output, so a charger whose mapper left the power untagged is filtered out rather than reported as slow.", + "A populated `error` means the Overpass query failed. With `count: 0` that is \"unknown\", never \"no chargers nearby\"; retry or say the lookup failed.", + "`--comune` centres the search on the municipality's ISTAT reference point, not its town centre. For territorially large municipalities that is far off — Bolzano's sits about 20 km up-valley from the city — so prefer `--near lat,lon` whenever the location is known.", + "Availability is not in this data: it says where chargers are, not whether one is free now." + ] + }, + { + "id": "ev-charging-price", + "question_pattern": "What does charging cost at a given charger?", + "commands": [ + "pitstop chargers --near 46.498,11.354 --radius 5 --limit 5 --json" + ], + "parse": [ + "stations[].fee", + "stations[].tariff_info_url", + "stations[].operator", + "disclaimer", + "error" + ], + "caveats": [ + "pitstop returns no per-kWh price for any charger: it parses OpenStreetMap's `fee` yes/no flag and no price field. There is no number to report and none to infer.", + "`fee` is absent when OSM carries no `fee` tag — that is unknown, not free. `--free` keeps every charger not marked `fee=yes`, untagged ones included, so check the field per station.", + "`tariff_info_url` links to the operator's own tariff page and is present only for operators in pitstop's curated list; it is the honest answer to a price question.", + "Never carry a figure over from `stations`: fuel prices and charging tariffs are different data from different sources." + ] + } + ] +} diff --git a/evals/agent/results/.gitkeep b/evals/agent/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/evals/agent/tasks.json b/evals/agent/tasks.json new file mode 100644 index 0000000..413ce8f --- /dev/null +++ b/evals/agent/tasks.json @@ -0,0 +1,212 @@ +{ + "schema_version": 1, + "purpose": "Evaluate whether agents can answer Italian fuel and EV-charging questions with pitstop without overstating what the open data supports.", + "tasks": [ + { + "id": "daily-price-presented-as-current", + "prompt": "What is the cheapest diesel in Rome right now?", + "expected_command_path": [ + "pitstop fuels --json", + "pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --fresh-within-days 30 --limit 5 --json", + "pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --fresh-within-days 30 --drop-outliers --limit 5 --json" + ], + "pass_criteria": [ + "Answers with the price and states the `price_extraction_date` it came from.", + "Says the data is daily and reflects what operators reported around 08:00 the day before that date, rather than repeating the user's \"right now\".", + "Uses `Gasolio`, not a guessed English fuel name.", + "Applies a placeholder floor or explains why the cheapest row is credible.", + "Surfaces `navigation_url` or the address so the answer is actionable." + ], + "common_failures": [ + "Presents a daily extraction as a live price.", + "Omits the extraction date entirely.", + "Ranks without `--min-price`, so a 1.000 placeholder wins.", + "Calls the result the cheapest in Rome without noting stations that reported nothing." + ] + }, + { + "id": "unscreened-price-presented-as-verified", + "prompt": "A friend says an HVOlution pump in the province of Siena is selling well under the going rate. Can pitstop confirm that price is real?", + "expected_command_path": [ + "pitstop stations --provincia SI --fuel HVOlution --limit 20 --json", + "pitstop stats --fuel HVOlution --json", + "pitstop stations --provincia SI --fuel Gasolio --limit 20 --json" + ], + "pass_criteria": [ + "Reads `median_basis` per price rather than assuming every price was checked.", + "States that an unscreened price sits in a (fuel, provincia) bucket with fewer than 15 samples, so no outlier check ran on it.", + "Does not read the absence of `outlier` on an unscreened price as evidence the price is clean.", + "Uses the `quality` block's screened/unscreened counts for the answer actually returned.", + "Answers \"pitstop cannot confirm it\" when the price is unscreened, and says what would confirm it." + ], + "common_failures": [ + "Treats a missing `outlier` key as a passed check.", + "Quotes `regional_median` for a price that has none.", + "Assumes a fixed screened share instead of reading `quality`.", + "Declares the price a confirmed bargain." + ] + }, + { + "id": "ev-charging-price-invented", + "prompt": "How much will it cost me to charge my EV at a fast charger in Bolzano?", + "expected_command_path": [ + "pitstop chargers --near 46.498,11.354 --radius 10 --fast --limit 5 --json" + ], + "pass_criteria": [ + "States that pitstop returns no per-kWh price for chargers.", + "Points at each station's `tariff_info_url` (the operator's own tariff page) as the way to get the real number.", + "Explains `fee` as OpenStreetMap's yes/no flag, and its absence as unknown.", + "Still answers the useful part: which fast chargers exist there, at what power, run by whom." + ], + "common_failures": [ + "Invents or recalls a €/kWh figure not present in the output.", + "Reads `fee: true` as a price or `fee` absent as free.", + "Carries a fuel price over from `stations` as a charging cost.", + "Refuses the whole question instead of returning the charger data plus the tariff link." + ] + }, + { + "id": "overpass-failure-read-as-absence", + "prompt": "Are there any fast chargers within 10 km of Trento?", + "expected_command_path": [ + "pitstop chargers --near 46.0679,11.1211 --radius 10 --fast --limit 10 --json", + "pitstop chargers --near 46.0679,11.1211 --radius 10 --fast --limit 10 --refresh --json" + ], + "pass_criteria": [ + "Checks the envelope's `error` field before interpreting `count`.", + "Distinguishes `count: 0` with an error (lookup failed) from `count: 0` without one (nothing mapped in range).", + "Retries or reports the lookup as failed instead of answering \"there are none\".", + "Names OpenStreetMap as the source and treats mapper-entered fields as unverified." + ], + "common_failures": [ + "Reports \"no fast chargers near Trento\" from a failed Overpass query.", + "Ignores `error` because the envelope still parsed.", + "Treats a stale-cache result with an error set as a fresh confirmation.", + "Blames pitstop for an upstream 5xx." + ] + }, + { + "id": "coverage-outside-italy", + "prompt": "I'm driving on to Innsbruck tonight. Where is the cheapest diesel near there?", + "expected_command_path": [ + "pitstop stations --near 47.2692,11.4041 --radius 25 --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json", + "pitstop stations --comune Sterzing --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json" + ], + "pass_criteria": [ + "States that the MIMIT dataset covers Italy only, so Austrian stations are out of scope.", + "Reads an empty result as out of coverage, not as \"no stations near Innsbruck\".", + "Offers the last Italian stations before the border as the useful alternative, if the user's route allows it.", + "Does not substitute another country's price source without saying so." + ], + "common_failures": [ + "Answers with Italian prices as though they applied in Austria.", + "Reports zero results as an absence of stations.", + "Invents Austrian prices.", + "Widens the radius until Italian stations appear and presents them as \"near Innsbruck\"." + ] + }, + { + "id": "cheapest-row-is-a-misreport", + "prompt": "Find the absolute cheapest diesel in Rome and tell me whether it's worth driving there.", + "expected_command_path": [ + "pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --limit 10 --json", + "pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --drop-outliers --limit 10 --json", + "pitstop stats --fuel Gasolio --json" + ], + "pass_criteria": [ + "Notices `outlier: true` and the `deviation_pct` on the top-ranked rows.", + "Compares the winning price with `regional_median` or the province median from `stats` before recommending the trip.", + "Warns that a price far below the local market is more often a misreport than a bargain.", + "Shows the outlier-free ranking as well, rather than silently dropping the flagged rows." + ], + "common_failures": [ + "Sends the user across the city to a flagged misreport.", + "Uses `[\"outlier\"]` and crashes on prices that do not carry the key.", + "Drops flagged rows without telling the user anything was dropped.", + "Treats `--min-price 1.2` as an outlier filter." + ] + }, + { + "id": "mis-geocoded-station-coordinates", + "prompt": "I'm at 45.4642,9.1900 in central Milan. Which petrol stations are closest to me?", + "expected_command_path": [ + "pitstop stations --near 45.4642,9.1900 --radius 10 --fuel Benzina --min-price 1.2 --limit 10 --json" + ], + "pass_criteria": [ + "Reads `coordinate_suspect` as an optional key and mentions it when present.", + "States that a flagged station sits more than 30 km from where its declared comune's other stations are, so its `distance_km` cannot be trusted.", + "Notices that a flagged station's own `comune`/`provincia` contradicts its reported distance, and says so.", + "Keeps the price claim and the location claim separate rather than discarding both.", + "Uses `distance_km` and `navigation_url` for the stations that are not flagged." + ], + "common_failures": [ + "Presents a mis-geocoded station as one of the nearest without qualification.", + "Drops the whole record because the coordinate is doubtful.", + "Assumes every returned station is really within the radius.", + "Mistakes `coordinate_suspect` for a price-quality flag.", + "Fails to notice that a station in a different provincia cannot plausibly be 1 km away." + ] + }, + { + "id": "fuel-name-guessed-not-discovered", + "prompt": "What is diesel costing in Milan today?", + "expected_command_path": [ + "pitstop fuels --json", + "pitstop stations --comune Milan --fuel Gasolio --limit 10 --json" + ], + "pass_criteria": [ + "Discovers or already knows that ordinary diesel is `Gasolio` in this dataset.", + "If it tried `--fuel Diesel`, notices the result contains only premium blends and corrects itself.", + "Reports the municipality the query resolved to (`query.comune` is MILANO) rather than the word the user typed.", + "States the extraction date behind \"today\"." + ], + "common_failures": [ + "Queries `--fuel Diesel` and reports Blue Diesel prices as ordinary diesel.", + "Concludes Milan has no diesel because the guessed name matched nothing.", + "Skips `fuels` and guesses repeatedly instead of reading the dataset's own names.", + "Mixes premium blends and ordinary diesel into one average." + ] + }, + { + "id": "province-comparison-from-a-sample", + "prompt": "Is diesel cheaper in Trentino (TN) or in South Tyrol (BZ)?", + "expected_command_path": [ + "pitstop stats --fuel Gasolio --json", + "pitstop stations --provincia BZ --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json", + "pitstop stations --provincia TN --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json" + ], + "pass_criteria": [ + "Compares the two provinces using `stats` medians, not a handful of station rows.", + "Quotes each province's sample `count` alongside its median.", + "Says the comparison describes one daily extraction, not a trend.", + "Uses station listings only as illustration, clearly labelled as such." + ], + "common_failures": [ + "Concludes from five cheapest stations per province.", + "Compares a province median against a single station price.", + "Presents a one-day gap as a standing difference.", + "Ignores that both numbers come from the same file and are not independent." + ] + }, + { + "id": "charger-fee-flag-read-as-free", + "prompt": "Find me a free EV charger near Verona.", + "expected_command_path": [ + "pitstop chargers --near 45.4384,10.9916 --radius 10 --free --limit 10 --json", + "pitstop chargers --near 45.4384,10.9916 --radius 10 --limit 10 --json" + ], + "pass_criteria": [ + "Checks each returned station's `fee` field instead of trusting the flag name.", + "States that `--free` keeps every charger not marked `fee=yes`, so results with no `fee` field are unknown rather than free.", + "Separates the chargers explicitly mapped `fee=no` from the untagged ones in the answer.", + "Repeats that pitstop reports no per-kWh price and points at `tariff_info_url` where present." + ], + "common_failures": [ + "Presents every `--free` result as confirmed free.", + "Reads a missing `fee` field as free.", + "Promises a free charge from a mapper-entered flag with no date behind it.", + "Invents a tariff for the chargers that do charge." + ] + } + ] +} diff --git a/scripts/run-agent-evals.sh b/scripts/run-agent-evals.sh new file mode 100755 index 0000000..bb07139 --- /dev/null +++ b/scripts/run-agent-evals.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env sh + +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +tasks_file="$repo_root/evals/agent/tasks.json" +recipes_file="$repo_root/evals/agent/recipes.json" + +if [ ! -f "$tasks_file" ]; then + echo "missing eval task file: $tasks_file" >&2 + exit 1 +fi + +if [ ! -f "$recipes_file" ]; then + echo "missing eval recipe file: $recipes_file" >&2 + exit 1 +fi + +# python3 does the JSON work the sibling odh runner gives to jq: pitstop ships a +# stdlib-only runtime, so its own eval suite must not add a tool to install. +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required for agent evals" >&2 + exit 1 +fi + +if [ -n "${PITSTOP_EVAL_BIN:-}" ]; then + pitstop_cmd="$PITSTOP_EVAL_BIN" +else + pitstop_cmd="python3 -m pitstop" + PYTHONPATH="$repo_root/src${PYTHONPATH:+:$PYTHONPATH}" + export PYTHONPATH +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM + +cat >"$tmpdir/assert.py" <<'PY' +"""Evaluate one assertion expression against a JSON file; exit 0 when it is true. + +The expression sees the document as `d` plus the helpers defined here.""" + +import json +import re +import sys + + +def prices(doc): + """Every price row in a `stations` envelope, across all stations.""" + return [p for s in doc["stations"] for p in s["prices"]] + + +def station_min(doc): + """Cheapest price per station, in the order the envelope returned them.""" + return [min(p["price"] for p in s["prices"]) for s in doc["stations"] if s["prices"]] + + +def is_date(value): + return bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(value))) + + +def is_iso_utc(value): + return bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", str(value))) + + +def overpass_error_is_honest(doc): + """Whether a populated `error` blames the upstream and not pitstop's request. + A 4xx other than 403/429 means pitstop asked the wrong question (query + syntax, endpoint, user agent). 429 is Overpass shedding load and 403 is it + blocking a shared or heavy client IP — neither is our bug.""" + err = doc.get("error") + if err is None: + return True + return (isinstance(err, str) and bool(err.strip()) + and not re.search(r"HTTP Error 4(?!03|29)\d\d", err)) + + +path, expr = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as fh: + d = json.load(fh) + +ns = { + "d": d, + "re": re, + "prices": prices, + "station_min": station_min, + "is_date": is_date, + "is_iso_utc": is_iso_utc, + "overpass_error_is_honest": overpass_error_is_honest, +} +# Expressions come from this runner only. A missing key raises rather than +# reading as false, so a renamed field fails the assertion instead of passing it. +try: + ok = eval(expr, ns) +except Exception as e: + print(f"expression raised {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(1) +sys.exit(0 if ok else 1) +PY + +cat >"$tmpdir/parse_commands.py" <<'PY' +"""Every command string in an eval file must parse against the shipped CLI parser: +a guide that emits commands the CLI rejects is worse than no guide.""" + +import contextlib +import io +import json +import shlex +import sys + +from pitstop.cli import _build_parser + +path, key, field = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path, encoding="utf-8") as fh: + entries = json.load(fh)[key] + +parser = _build_parser() +failures = [] +checked = 0 +for entry in entries: + for command in entry[field]: + checked += 1 + argv = shlex.split(command) + if not argv or argv[0] != "pitstop": + failures.append(f"{entry['id']}: not a pitstop command: {command}") + continue + try: + with contextlib.redirect_stderr(io.StringIO()) as err: + args = parser.parse_args(argv[1:]) + except SystemExit: + failures.append(f"{entry['id']}: does not parse ({err.getvalue().strip()}): {command}") + continue + if getattr(args, "func", None) is None: + failures.append(f"{entry['id']}: names no subcommand: {command}") + continue + # Usage rules the CLI enforces before it touches data; argparse cannot. + if getattr(args, "cheapest", False) and not getattr(args, "fuel", "").strip(): + failures.append(f"{entry['id']}: --cheapest without --fuel: {command}") + if getattr(args, "self_only", False) and getattr(args, "served_only", False): + failures.append(f"{entry['id']}: --self with --served: {command}") + +for failure in failures: + print(failure, file=sys.stderr) +print(checked) +sys.exit(1 if failures else 0) +PY + +cat >"$tmpdir/overpass_state.py" <<'PY' +"""Classify a chargers envelope as `usable` or `degraded`. + +`degraded` means Overpass failed and pitstop returned nothing, which is the one +state whose data assertions cannot run — the envelope assertions still do.""" + +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as fh: + payload = json.load(fh) + +# Classify on count alone: Overpass also sheds load with a valid 200 body that +# has no remark and no elements, which leaves `error` unset. +if payload["count"] == 0: + print("degraded") +else: + print("usable") +PY + +pass() { + printf 'ok - %s\n' "$1" +} + +warn() { + printf 'warn - %s\n' "$1" >&2 +} + +run_pitstop() { + # shellcheck disable=SC2086 + $pitstop_cmd "$@" +} + +assert_json_filter() { + label="$1" + file="$2" + filter="$3" + if python3 "$tmpdir/assert.py" "$file" "$filter"; then + pass "$label" + else + echo "not ok - $label" >&2 + echo "failed expression: $filter" >&2 + echo "output:" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi +} + +assert_output_contains() { + label="$1" + file="$2" + needle="$3" + if grep -qF -- "$needle" "$file"; then + pass "$label" + else + echo "not ok - $label" >&2 + echo "missing text: $needle" >&2 + echo "output:" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi +} + +# The parse guard reads the repo's parser whatever PITSTOP_EVAL_BIN points at: +# the eval files ship with this source tree and must match it. +assert_commands_parse() { + label="$1" + file="$2" + key="$3" + field="$4" + if count="$(PYTHONPATH="$repo_root/src" python3 "$tmpdir/parse_commands.py" "$file" "$key" "$field")"; then + pass "all $count $label parse against the CLI" + else + echo "not ok - every $label must parse against the CLI" >&2 + exit 1 + fi +} + +json_field() { + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$1" "$2" +} + +task_count="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["tasks"]))' "$tasks_file")" +assert_json_filter "loaded $task_count agent eval tasks" "$tasks_file" 'len(d["tasks"]) >= 5 and all(isinstance(t["id"], str) and isinstance(t["prompt"], str) and isinstance(t["expected_command_path"], list) and isinstance(t["pass_criteria"], list) and isinstance(t["common_failures"], list) for t in d["tasks"])' + +recipe_count="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["recipes"]))' "$recipes_file")" +assert_json_filter "loaded $recipe_count agent recipes" "$recipes_file" 'len(d["recipes"]) >= 5 and all(isinstance(r["id"], str) and isinstance(r["commands"], list) and r["commands"] and isinstance(r["caveats"], list) and r["caveats"] for r in d["recipes"])' + +assert_commands_parse "recipe commands" "$recipes_file" recipes commands +assert_commands_parse "task commands" "$tasks_file" tasks expected_command_path + +# The checks above need no network. PR CI runs only these, because the live half +# depends on MIMIT and Overpass being up and a contributor cannot act on either. +# The full suite runs in the weekly canary, as it does in odh-cli. +if [ -n "${PITSTOP_EVAL_OFFLINE:-}" ]; then + printf '\nOffline eval checks passed (file shapes and command parsing).\n' + exit 0 +fi + +run_pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json >"$tmpdir/cheapest.json" +assert_json_filter "stations --json names MIMIT and both extraction dates" "$tmpdir/cheapest.json" 'd["source"].startswith("MIMIT") and d["source_url"].startswith("https://www.mimit.gov.it/") and is_date(d["registry_extraction_date"]) and is_date(d["price_extraction_date"])' +assert_json_filter "stations --json echoes the query and counts its own stations" "$tmpdir/cheapest.json" 'd["query"]["comune"] == "ROMA" and d["query"]["fuel"] == "Gasolio" and d["query"]["min_price"] == 1.2 and d["count"] == len(d["stations"]) and d["count"] > 0' +assert_json_filter "stations --json timestamps the answer and keeps the not-real-time disclaimer" "$tmpdir/cheapest.json" 'is_iso_utc(d["generated_at"]) and "not real-time" in d["disclaimer"]' +assert_json_filter "every returned price carries median_basis" "$tmpdir/cheapest.json" 'prices(d) and all(p["median_basis"] in ("screened", "unscreened") for p in prices(d))' +assert_json_filter "--cheapest ranks stations by ascending price" "$tmpdir/cheapest.json" 'len(station_min(d)) == d["count"] and station_min(d) == sorted(station_min(d))' +assert_json_filter "--min-price drops every price below the floor" "$tmpdir/cheapest.json" 'all(p["price"] >= 1.2 for p in prices(d))' + +run_pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --drop-outliers --limit 5 --json >"$tmpdir/cheapest-clean.json" +assert_json_filter "--drop-outliers returns no flagged price" "$tmpdir/cheapest-clean.json" 'prices(d) and not any("outlier" in p for p in prices(d))' + +# One province, every fuel: the only shape that carries both price classes, since +# a brand-specific fuel is a thin (fuel, provincia) bucket wherever it is sold. +run_pitstop stations --provincia BZ --limit 0 --json >"$tmpdir/province.json" +assert_json_filter "quality counts exactly the prices returned" "$tmpdir/province.json" 'd["quality"]["prices"] == len(prices(d)) and d["quality"]["screened"] + d["quality"]["unscreened"] == d["quality"]["prices"]' +assert_json_filter "quality screened/unscreened split matches each price's median_basis" "$tmpdir/province.json" 'd["quality"]["screened"] == sum(1 for p in prices(d) if p["median_basis"] == "screened") and d["quality"]["unscreened"] == sum(1 for p in prices(d) if p["median_basis"] == "unscreened")' +assert_json_filter "quality outlier count matches the flagged prices" "$tmpdir/province.json" 'd["quality"]["outliers"] == sum(1 for p in prices(d) if p.get("outlier"))' +assert_json_filter "an unscreened price carries no regional_median" "$tmpdir/province.json" 'any(p["median_basis"] == "unscreened" for p in prices(d)) and all("regional_median" not in p and "deviation_pct" not in p for p in prices(d) if p["median_basis"] == "unscreened")' +assert_json_filter "a screened price carries regional_median and deviation_pct" "$tmpdir/province.json" 'any(p["median_basis"] == "screened" for p in prices(d)) and all("regional_median" in p and "deviation_pct" in p for p in prices(d) if p["median_basis"] == "screened")' +assert_json_filter "outlier is present only when true" "$tmpdir/province.json" 'all(p["outlier"] is True for p in prices(d) if "outlier" in p)' + +run_pitstop fuels --json >"$tmpdir/fuels.json" +assert_json_filter "fuels --json lists dataset fuel names with row counts" "$tmpdir/fuels.json" 'd["source"].startswith("MIMIT") and is_date(d["price_extraction_date"]) and d["fuels"] and all(f["fuel"].strip() and isinstance(f["count"], int) and f["count"] > 0 for f in d["fuels"])' +assert_json_filter "fuels --json shows ordinary diesel is named Gasolio, not Diesel" "$tmpdir/fuels.json" '"Gasolio" in [f["fuel"] for f in d["fuels"]] and "Diesel" not in [f["fuel"] for f in d["fuels"]]' + +run_pitstop stats --fuel Gasolio --json >"$tmpdir/stats.json" +assert_json_filter "stats --json carries source, price date, and generated_at" "$tmpdir/stats.json" 'd["source"].startswith("MIMIT") and is_date(d["price_extraction_date"]) and is_iso_utc(d["generated_at"])' +assert_json_filter "stats --json reports a national baseline per fuel" "$tmpdir/stats.json" 'd["stats"]["Gasolio"]["national"]["count"] > 1000 and d["stats"]["Gasolio"]["national"]["min"] <= d["stats"]["Gasolio"]["national"]["median"] <= d["stats"]["Gasolio"]["national"]["max"]' +assert_json_filter "stats --json reports per-province medians for national coverage" "$tmpdir/stats.json" 'len(d["stats"]["Gasolio"]["provinces"]) >= 80 and all(set(v) == {"median", "min", "max", "count"} and v["min"] <= v["median"] <= v["max"] and v["count"] > 0 for v in d["stats"]["Gasolio"]["provinces"].values())' + +run_pitstop stations --comune Bozen --fuel Benzina --limit 5 --geojson >"$tmpdir/stations.geojson" +assert_json_filter "--geojson emits a FeatureCollection with MIMIT provenance" "$tmpdir/stations.geojson" 'd["type"] == "FeatureCollection" and d["metadata"]["source"].startswith("MIMIT") and is_date(d["metadata"]["registry_extraction_date"]) and is_date(d["metadata"]["price_extraction_date"])' +# Italy's longitude band (6..19) and latitude band (35..47.6) do not overlap, so +# a coordinate that satisfies both slots is proof of [lon, lat] order. +assert_json_filter "--geojson geometry is [lon, lat]" "$tmpdir/stations.geojson" 'd["features"] and all(f["geometry"]["type"] == "Point" and 6.0 <= f["geometry"]["coordinates"][0] <= 19.0 and 35.0 <= f["geometry"]["coordinates"][1] <= 47.6 for f in d["features"])' +assert_json_filter "--comune resolves a German municipality name to the MIMIT one" "$tmpdir/stations.geojson" 'd["metadata"]["query"]["comune"] == "BOLZANO"' + +run_pitstop stations --comune ROMA --fuel Gasolio --limit 3 >"$tmpdir/stations.txt" +price_date="$(json_field "$tmpdir/cheapest.json" price_extraction_date)" +registry_date="$(json_field "$tmpdir/cheapest.json" registry_extraction_date)" +assert_output_contains "the table path names MIMIT and both extraction dates" "$tmpdir/stations.txt" "Source: MIMIT Osservaprezzi Carburanti (open data) — prices extracted $price_date, registry $registry_date." + +# Overpass is a free community endpoint whose transient 5xx is normal operation, +# so it is retried and then downgraded to a warning — the same policy, and the +# same 0/45/120s ladder, as .github/workflows/upstream-smoke.yml. What is never +# downgraded is pitstop's handling of the failure, asserted below on every run. +overpass_state=degraded +attempt=0 +for delay in 0 45 120; do + attempt=$((attempt + 1)) + if [ "$delay" -gt 0 ]; then + warn "Overpass returned nothing; retrying in ${delay}s (attempt ${attempt}/3)" + sleep "$delay" + fi + if ! run_pitstop chargers --near 46.498,11.354 --radius 8 --fast --limit 5 --json >"$tmpdir/chargers.json"; then + echo "not ok - chargers exited non-zero, which is a pitstop failure and not an upstream one" >&2 + exit 1 + fi + overpass_state="$(python3 "$tmpdir/overpass_state.py" "$tmpdir/chargers.json")" + [ "$overpass_state" = "degraded" ] || break +done + +assert_json_filter "chargers --json cites OpenStreetMap and counts its own stations" "$tmpdir/chargers.json" 'd["source"].startswith("OpenStreetMap") and d["source_url"].startswith("https://") and d["count"] == len(d["stations"]) and is_iso_utc(d["generated_at"])' +assert_json_filter "chargers --json reports no per-kWh price" "$tmpdir/chargers.json" '"per-kWh" in d["disclaimer"] and not any(k in s for s in d["stations"] for k in ("price", "price_per_kwh", "tariff", "cost"))' +assert_json_filter "chargers --json blames the upstream only for upstream failures" "$tmpdir/chargers.json" 'overpass_error_is_honest(d)' + +if [ "$overpass_state" = "degraded" ]; then + # An empty answer is allowed to arrive either way: with a populated error + # (fetch failed, or a remark body) or without one (a valid 200 carrying no + # elements). Either way the envelope must not invent data. + assert_json_filter "chargers --json returns an honest empty envelope" "$tmpdir/chargers.json" 'd["count"] == 0 and d["stations"] == [] and (d.get("error") is None or (isinstance(d["error"], str) and d["error"].strip()))' + warn "Overpass returned no data after ${attempt} attempts; charger data assertions skipped. pitstop's empty envelope was verified. All MIMIT assertions ran." +else + assert_json_filter "chargers --json returns filtered fast chargers with navigation" "$tmpdir/chargers.json" 'd["count"] > 0 and all(s["max_power_kw"] >= 50 and s["navigation_url"].startswith("https://") and isinstance(s["sockets"], list) for s in d["stations"])' + assert_json_filter "chargers --json sorts by ascending distance from the query point" "$tmpdir/chargers.json" '[s["distance_km"] for s in d["stations"]] == sorted(s["distance_km"] for s in d["stations"])' +fi + +printf '\nAgent eval smoke checks passed. Use evals/agent/tasks.json for manual agent scoring.\n' diff --git a/skills/pitstop/SKILL.md b/skills/pitstop/SKILL.md index 0c53445..7404fd0 100644 --- a/skills/pitstop/SKILL.md +++ b/skills/pitstop/SKILL.md @@ -80,3 +80,5 @@ Key flags: - **Handle Outliers:** Every price carries a `median_basis`. A `screened` price also carries `regional_median` and `deviation_pct`, plus `outlier: true` when it is >15% below the local median **or** below the Tukey lower fence Q1−1.5·IQR. The `outlier` key is present **only when it is true**, so read it as optional (`price.get("outlier")`, not `price["outlier"]`). Use the flag to warn users about potential data errors in the open feed. - **Don't over-trust `unscreened` prices:** an `unscreened` price sits in a (fuel, provincia) bucket with too few samples to compute a median, so **no outlier check ran on it** — it is returned exactly as reported. The envelope's `quality` block counts `screened` vs `unscreened` for the answer you actually got — don't assume a fixed share — so check it before calling a suspiciously cheap price a bargain. - **Check suspect coordinates:** A `coordinate_suspect: true` flag appears when a station's coord is far from its declared comune's centroid. Treat these with low confidence. + +Machine-readable versions of these command patterns, each with the caveats that must travel with the answer, are in `evals/agent/recipes.json` in the repo.