pplx-decider-v1.2 with six yes-or-no questions (“Is any text clipped?”, “Do elements overlap?”, “Is the call to action visible?”), and gets six numbers from 0 to 1. For a pull request it also sends the main and pull-request screenshots together and asks whether content went missing and whether the change looks deliberate. Your code compares the numbers to cutoffs, writes a Markdown report into the GitHub Actions job summary with the probabilities and the rule that fired, and exits nonzero when a failure rule fires. The model reads the screenshots and returns the probabilities; your code fails the build.
This recipe is three Python files, a small sample site with a landing page and a pricing page, and a GitHub Actions workflow. The sample site has three stylesheets: main, a pull request that breaks the mobile pricing table, and a deliberate redesign. The recorded runs below fail the broken variant and pass the redesign; they are fixture checks, not a guarantee for your site.
Understand why a probability beats a pixel diff
- Numbers you can threshold.
text_clippedcame back at 96% for the broken mobile pricing page. Your code can compare that probability to an 80% cutoff without parsing a free-text judgment. - It reads meaning, not pixels. A redesign that changed the hero to dark navy, recolored every button, and rounded the corners scored
content_lost0% andintentional99%. A pixel diff of the same change is red on every page. - Six questions, one request.
pplx-decider-v1.2answers all six questions about one screenshot in one request and bills the screenshot once. The recorded run below includes the actual input-token totals. - Two images, two questions.
stateis an array, so the before-and-after pair goes in one request with captions between the images. The model sees them in the order you write them.
See what you will build
A command-line tool that serves the sample site, screenshots it, asks the questions, judges the answers, and writes the report. The same tool takes--url and --baseline-url for a site that is already running, such as a preview deployment against production.
A recorded run, under a minute. A pull request drops the mobile media query, so the pricing page breaks at 390 px while desktop looks fine. A pixel diff flags the PR (11% changed) and a deliberate redesign (23% changed) alike. The decision model answers six yes/no questions per screenshot: the PR fails on text_clipped 96% and content_lost 96%, the redesign passes with intentional 94% and content_lost 4%. Exit codes 1 and 0. Model: pplx-decider-v1.2.
visual_qa.pyservessite/on a free local port, with the chosen variant’s stylesheet as/theme.css.- Playwright loads the page at 1280 px and at 390 px and takes a full-page screenshot of each.
decisions_client.pysends the screenshot and six questions to the Decisions API and gets six probabilities back.- With a baseline, Playwright screenshots the baseline too, and
decisions_client.pysends both images with two more questions. policy.pycompares every probability to the cutoffs, names the rule that fired, and renders the report.visual_qa.pywritesout/report.md, appends it to$GITHUB_STEP_SUMMARYwhen that variable is set, and exits 1 if any verdict isfail.
Gather what you need
- Python 3.10 or newer on macOS or Linux. The commands below use Unix shell syntax.
- A Perplexity API key from the API Console.
- About 700 MB of free disk space for the Chromium build that Playwright downloads.
Set up the project
Create the folder, saverequirements.txt and pyproject.toml in it, then run the remaining commands.
requirements.txt
requirements.txt
httpx sends the API requests. playwright controls the browser. pytest runs the tests. pyproject.toml holds settings for ruff (code style), mypy (type checks), and pytest. You do not need ruff or mypy to run the gate.
pyproject.toml
pyproject.toml
Install and set your key
Install and set your key
source and export lines again.
Explore the sample site
Six files insite/. Both pages link two stylesheets: /styles.css, which is the shared layout, and /theme.css, which the local server maps to main.css, pr.css, or redesign.css depending on the variant you ask for. That is the whole mechanism for “main versus this pull request”: same HTML, different stylesheet.
site/index.html
site/index.html
site/pricing.html
site/pricing.html
site/styles.css
site/styles.css
styles.css. .plans is a three-column grid with minmax(0, 1fr) tracks, and each .plan has min-width: 280px. On a 1280 px screen the tracks are about 315 px wide and nothing happens. At 390 px each track is about 100 px, every card insists on 280 px, and the grid lets them overlap. main.css fixes that with one media query. pr.css is what a cleanup commit left behind after removing it. redesign.css keeps the media query and changes the look.
site/main.css
site/main.css
site/pr.css
site/pr.css
site/redesign.css
site/redesign.css
site/ inside the project folder.
Choose the cutoffs before writing code
The gate maps probabilities to three outcomes:fail stops the build, warn passes the build but asks a person to look at the screenshot, and pass is silent. Before writing the gate, decide what those cutoffs should be and what a good result looks like.
Two things can go wrong. The gate can miss a real defect, which is the bug the recipe exists to catch. Or it can flag a clean screenshot, which is the reason teams stop reading pixel-diff reports. The acceptance criteria bound both:
- At most 10% of defects pass silently.
- At most 1% of clean screenshots fail the build.
- At most 10% of clean screenshots are flagged at all (
warnorfail).
test_visual_qa.py checks these on a synthetic distribution of 20,000 screenshots: 85% clean, with defect questions mostly near zero and a noisy tail that crosses 40% on a few percent of them; 15% carrying one defect, three quarters of them obvious (probabilities around 85%) and one quarter subtle (spread around 50%). The baseline to beat is the trivial policy, review everything: it misses nothing and flags everything, so if the gate cannot do far better it is not worth running.
policy.py: FAIL_AT = 0.80, WARN_AT = 0.40. cta_visible is the one question where a high probability is good, so its cutoffs are mirrored: fail at or below 20%, warn at or below 60%.
The before-and-after pair has its own rule. content_lost at or above 80% with intentional at or below 40% is a regression and fails. content_lost at or above 40% with anything else is a warning: a deliberate removal still deserves a look, but it should not stop the build. The second synthetic test runs 20,000 pairs through that rule with 10% regressions, 15% redesigns (some of which remove content on purpose), and 75% unchanged pages, and holds the same three bounds.
The distribution is synthetic. It stands in for the labeled screenshots you do not have yet; once you do, replace the generator in the tests with your own probabilities and keep the acceptance criteria.
Write the API client: decisions_client.py
decisions_client.py calls the Decisions API and knows nothing about browsers or cutoffs. Copy the complete file from Copy the complete files, then read it here in four parts.
Configure the endpoint, model, and retry settings
Endpoint, model, and retry settings
Endpoint, model, and retry settings
MODELispplx-decider-v1.2. Flash processes grouped questions separately and bills the state for each question, so a lower input-token price does not mean a lower cost for this six-question request. Keep this recipe onpplx-decider-v1.2; see Grouping questions in one request.REQUEST_TIMEOUT_S,RETRY_STATUSES,MAX_RETRIES, andMAX_RETRY_DELAY_Scontrol the timeout and retries. A 429 (rate limit), a 5xx (server error), or a dropped connection is retried twice. A 400 or 401 is a problem with the request and is not retried. If the server asks for a wait longer than 30 seconds, the script stops with an error rather than retry early.
Define the eight questions
The eight questions
The eight questions
noul questions: the answer to each is the probability of yes. The six in SINGLE_QUESTIONS go with one screenshot. Five of them describe defects, so a high probability is bad. cta_visible is the exception: it asks whether the main button is fully visible, so a low probability is bad. The two in PAIR_QUESTIONS go with two screenshots and refer to them as BEFORE and AFTER, the same words the request puts in front of each image.
The questions name no product, page, or site. They describe what a broken page looks like in general, so the same six questions work on the sample site and on yours.
Build the request bodies
Request bodies
Request bodies
image_part wraps a PNG as the OpenAI-style image part the Decisions API reads: {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}. PNG, JPEG, and WebP data URLs are accepted; http URLs return 400.
single_body builds the state as a two-item array: a one-line caption such as “Screenshot of pricing at 390 px.” and the image. pair_body builds a five-item array: a caption, the word BEFORE, the baseline image, the word AFTER, the new image. The model reads state top to bottom with each image placed where its part appears, so the captions tell it which picture is which.
Send one request with retries
One request, with retries
One request, with retries
ask sends one request and returns three things: a dictionary of probabilities keyed by question name, the input tokens from usage, and the x-request-id header. It takes the httpx.Client as an argument rather than creating its own, so the tests can pass a fake client that returns prepared responses.
The loop makes up to three attempts. A 200 returns the parsed answers. A 429, 5xx, or dropped connection waits and retries; _retry_delay uses the Retry-After header when the server sends a number, otherwise 1 second, then 2. Any other status raises DecisionsError with the status code, the API’s message, and the request id. Include that id in any support request.
_parse reads answers[name]["noul"] for every question the request asked and usage.input_tokens. A 200 missing any of them becomes a DecisionsError instead of a KeyError later.
Write the policy and report: policy.py
policy.py turns probabilities into verdicts and verdicts into Markdown. It imports nothing from the other two files. Copy the complete file from Copy the complete files, then read it here in three parts.
Turn cutoffs into a verdict
Cutoffs and the verdict
Cutoffs and the verdict
FAIL_AT and WARN_AT are the two cutoffs from Choose the cutoffs before writing code. CTA_FAIL_BELOW and CTA_WARN_BELOW are their mirror images for cta_visible. ORDER ranks the outcomes so the worst one can be found with min.
A Verdict is one row of the report: the screenshot it is about, the outcome, the rule that fired in plain words with the probability in it, every probability from the response, and the path of the screenshot so a reader of the report can open the picture.
Judge one screenshot and one pair
Judging one screenshot and one pair
Judging one screenshot and one pair
judge_single finds the defect question with the highest probability and compares it to the cutoffs, then does the same for cta_visible on the mirrored scale. The rule string carries the question name and the probability, so the report says text_clipped 96% is at or above 80% rather than just “fail”.
judge_pair checks content_lost first. Lost content with a low intentional probability is a regression and fails. Lost content with a high intentional probability is a warning for a person to confirm. If nothing was lost, intentional is not consulted at all; a recolored button is not a bug.
overall is the worst verdict in the run, and an empty run passes.
Render the report
The report
The report
render_report writes GitHub-flavored Markdown: a headline with the overall result, one line with what was checked and the total input tokens, a table with one row per verdict, and a collapsed <details> block with every probability and screenshot path. GitHub renders all of this in the job summary, and the table is short enough to read on a phone.
Write the gate: visual_qa.py
visual_qa.py serves the site, takes the screenshots, runs the loop, and owns the command line. Copy the complete file from Copy the complete files, then read it here in five parts.
Declare imports and constants
Imports and constants
Imports and constants
VIEWPORTS is desktop at 1280 by 800 and mobile at 390 by 844, the logical size of a recent iPhone. MAX_TILES is the Decisions API’s limit of 2,048 tiles of 32 by 32 pixels per image. An image over that limit does not return 400; the request waits about a minute and returns 504, so the script keeps every screenshot under it.
Serve the site locally
The local server
The local server
SiteHandler is Python’s built-in static file handler with one change: a request for /theme.css is answered from site/<variant>.css. serve makes a subclass per variant, binds it to port 0 so the operating system picks a free port, and runs it on a daemon thread. With a baseline, two servers run at once, one per variant, and the gate shuts both down at the end. Logging is silenced so the console shows only the gate’s own lines.
Capture screenshots within the tile budget
Screenshots within the tile budget
Screenshots within the tile budget
max_height turns the tile budget into a pixel height for a given width: 1,632 px at 1280 wide, 5,440 px at 390 wide. screenshot opens a fresh browser context with reduced motion and waits for network idle plus a quarter second. It captures the full height at the viewport width, so horizontal overflow does not widen the image beyond what a phone user sees. It stops with an error if the height exceeds the budget, rather than silently skip lower content. For longer pages, add section-by-section capture. Disabling CSS animations reduces variation; dynamic content and late-loading fonts can still change screenshots.
Run the loop
The loop
The loop
run starts a server for each bundled variant you named, or uses the URLs you passed. For each page and each viewport it screenshots the head, asks the six questions, and judges the answers. With a baseline it also screenshots the baseline, sends the pair, and judges that. Every screenshot is saved in out/ under the variant or hostname, the page, and the viewport, and every verdict is printed as it happens with the input tokens and the request id.
After the loop, the script writes out/report.md and out/results.json, and appends the report to GITHUB_STEP_SUMMARY when that variable is set. API, browser, and image-budget errors exit 2, so a failed check is distinct from a detected visual defect.
Add the command line and entry point
Command line and entry point
Command line and entry point
--variant serves a bundled variant; --url checks a site that is already running. --baseline and --baseline-url add the before-and-after pairs. --pages defaults to the two sample pages. main refuses to start without PERPLEXITY_API_KEY.
Run the gate
Run the broken variant againstmain, check main by itself, then compare the deliberate redesign with main. These commands make 20 successful inference requests without retries.
GITHUB_STEP_SUMMARY.
Broken variant: recorded report
Broken variant: recorded report
Main: recorded report
Main: recorded report
Redesign: recorded report
Redesign: recorded report
Read the numbers
The model returned probabilities.policy.py produced the verdicts, and visual_qa.py set the exit code.
- Broken mobile pricing page:
text_clippedwas 96%,elements_overlap89%, andlayout_broken86%. The single-image rule failed on the highest score,text_clipped. In the pair,content_lostwas 96% andintentional32%, so that rule also failed. - Unchanged pages: The other six verdicts in the pull-request run passed. Uncertainty about whether an unchanged pair looks deliberate did not matter because
content_loststayed below the warning cutoff. - Deliberate redesign: All eight verdicts passed. The four pairs had
content_lostbetween 0% and 3% andintentionalbetween 96% and 99%. - Limits of this check: The broken variant changes only one CSS rule in a small fixture. This run does not establish false-positive or missed-defect rates for your site, and inferred intent is not proof of a designer’s intent. Add representative clean pages, real regressions, and intentional changes before making the gate a required check.
Add it to GitHub Actions
The workflow installs the dependencies and Chromium, runs the gate, and uploadsout/ as an artifact so the screenshots are a click away from the failed job. The report appears in the job summary because visual_qa.py writes to $GITHUB_STEP_SUMMARY on its own. Add PERPLEXITY_API_KEY as a repository secret first.
Put the recipe files at the repository root for this workflow, or set the steps’ working directory and artifact path to your recipe folder. The job skips fork pull requests because they do not receive repository secrets. Run it only on trusted code: do not switch to pull_request_target to give untrusted changes a key, and do not send sensitive screenshot content without permission.
visual-qa.yml
visual-qa.yml
python -m http.server, or use a preview deployment URL from your hosting provider, then run python visual_qa.py --url "$PREVIEW_URL" --baseline-url "$PRODUCTION_URL" --pages / /pricing. The exit code fails the job on any fail verdict; warnings show in the summary and let the job pass.
Adapt it to your site
- Your pages. Pass
--pageswith the paths that matter: the checkout, the dashboard after login, the empty state. For pages behind a login, add acontext.add_cookies(...)call inscreenshotwith a session cookie from a secret. - Your questions. Edit
SINGLE_QUESTIONS. A question about a broken image, a missing logo, or an untranslated string is one morenoulin the dictionary and one more entry inDEFECT_QUESTIONS, with no change to the loop. Keep the question general and let the screenshot carry the specifics. - A stricter gate. Lower
FAIL_ATto 0.7 and the synthetic test will tell you what that costs in clean screenshots failed. Rerunpytestafter any change to the cutoffs. - More viewports. Add entries to
VIEWPORTS. Keep the image within the tile budget enforced bymax_height; oversized pages stop the run rather than receive a partial check. - A real baseline. In CI the baseline is a deployed URL or a build of
main. Both work with--baseline-url.
Troubleshoot common problems
Set PERPLEXITY_API_KEYmeans the variable is not set in this terminal. Run theexportline again.page exceeds the image budgetmeans the page needs section-by-section capture. The script exits 2 without claiming the page passed.Decisions API failed: 401 {"error":{"message":"Invalid API key provided. Ensure your API key is correct and active.","type":"invalid_api_key","code":401}}means the key is wrong or inactive.Decisions API failed: 400is a problem with the request;error.messagegives the reason. The pair request sends two images per question, well under the limit of eight.- A request that waits about a minute and then fails with 504 means an image went over 2,048 tiles. If you changed
VIEWPORTSorMAX_TILES, checkmax_height. Decisions API failed: 429means your organization exceeded the request limit (10 per second) or the token limit, and two retries did not clear it. Another process is likely sharing the limit; wait and rerun.playwright._impl._errors.Error: Executable doesn't existmeans Chromium is not installed for this Playwright version. Runpython -m playwright install chromiumagain; in CI, use--with-deps.ModuleNotFoundError: No module named 'decisions_client'means a file is not next tovisual_qa.py.- The gate passes a page that looks broken to you. Open
out/results.jsonand look at the probabilities. If the right question is between 40% and 80%, it warned and the summary has the screenshot path; if it is below 40%, rephrase the question to describe what you see and rerun. - The gate warns on every run of a clean page. One question sits high at rest on your design. In the recorded runs,
low_contrastran from 6% to 13% on clean screenshots because the gray body text is light; on a site with lighter text it sits higher. RaiseWARN_AT, give that question its own cutoff injudge_single, or rephrase it, then rerunpytest.
Test the pieces
The tests use no API key, network, or browser. Savetest_visual_qa.py next to the other files and run pytest.
test_visual_qa.py
test_visual_qa.py
Copy the complete files
The exact files used for the runs above.requirements.txt
requirements.txt
pyproject.toml
pyproject.toml
decisions_client.py
decisions_client.py
policy.py
policy.py
visual_qa.py
visual_qa.py
test_visual_qa.py
test_visual_qa.py
visual-qa.yml
visual-qa.yml