AI SEO Planner with OpenRouter

Turn SEO opportunities into validated AI experiment plans with Python.

Introduction

30 Second Summary

Website reports often reveal pages that appear in search results without earning enough clicks. The harder question is choosing a change to test first.

In this project, you will build a command-line planner that turns existing Google Search Console opportunities into structured SEO experiments. Your planner will use the OpenRouter Free Models Router to create a validated, prioritized CSV with one plan for every row.

What You'll Build

Run your finished planner to watch three opportunities become a prioritized experiment backlog ready for review in Visual Studio Code.

By the end of this project, you'll have:

  • An exact input preview of every query and page pair sent for planning.
  • One validated experiment per opportunity that must satisfy a strict JSON Schema before export.
  • A prioritized backlog in experiments.csv that records the selected free model beside every complete recommendation.
  • Secret Mission: A separate high-priority review queue built from the same validated results without making another OpenRouter API request.

Are there any prerequisites?

You need a Linux machine with Visual Studio Code, an OpenRouter API key, and an internet connection. The supplied sample data covers anyone without output from the earlier project.

Before We Start

Before any hands-on work begins, lock in the SEO decision this planner should help you make. This commitment keeps the project focused on turning existing opportunities into measurable experiments.

Set Up a Reproducible Python Workspace

A planner can behave unpredictably when its Python runtime changes between machines. A pinned workspace gives every later script the same foundation.

This step locks the runtime to Python 3.14.7 with uv. You will also create a trusted workspace in Visual Studio Code.

In this step, get ready to:
  • Install uv 0.12.13.
  • Install Python 3.14.7 through uv.
  • Create a trusted workspace with three starter files.
Install uv 0.12.13

The uv command manages the Python version used by your planner. Pinning 0.12.13 keeps the setup reproducible.

  • Open a terminal window from your Linux application launcher.
  • Check the currently available uv version by running this command:
uv --version

What does this check do?

This command asks uv to report its installed version. It does not change your system.

This first check can show the pinned version, another version, or an unavailable command. Choose the tab that matches your terminal.

✔️ I see the pinned uv version

Your terminal reports uv 0.12.13. The pinned tool is already installed.

  • Close the current terminal window.
  • Open a new terminal window from your Linux application launcher.

The new terminal reloads your shell environment before you install Python.

ⓧ I see a different uv version

Your current uv version does not match the project pin. The version-specific installer updates the command used by this workspace.

  • Install uv 0.12.13 with curl by running this command:
curl -LsSf https://astral.sh/uv/0.12.13/install.sh | sh

What does this installer do?

The command downloads the version-specific installer over HTTPS. Your shell runs that installer to make uv available locally.

  • Use the official wget fallback if curl is unavailable:
wget -qO- https://astral.sh/uv/0.12.13/install.sh | sh

What does the fallback do?

This command retrieves the same pinned installer with wget. It gives Linux systems without curl the same installation result.

  • Close the terminal after the installer finishes.
  • Open a new terminal window from your Linux application launcher.

Did the installer stop early?

Confirm that your internet connection is active. Use the fallback command when curl is unavailable.

Still stuck? Help me diagnose the uv installation.

ⓧ uv is unavailable

Your Linux environment does not currently expose the uv command. The official installer adds the pinned release.

  • Install uv 0.12.13 with curl by running this command:
curl -LsSf https://astral.sh/uv/0.12.13/install.sh | sh

What does this installer do?

The command downloads the pinned installer over HTTPS. Your shell runs that installer to add uv locally.

  • Use the official wget fallback if curl is unavailable:
wget -qO- https://astral.sh/uv/0.12.13/install.sh | sh

What does the fallback do?

This command retrieves the same installer with wget. The installed version remains pinned to 0.12.13.

  • Close the terminal after the installer finishes.
  • Open a new terminal window from your Linux application launcher.

Did the installer stop early?

Confirm that your internet connection is active. Use the fallback command when curl is unavailable.

Still stuck? Help me install the pinned uv release.

  • Confirm the pinned version in the newly opened terminal by running this command:
uv --version

Why check again?

A newly opened terminal reloads the command locations configured by the installer. This check confirms that your shell can now find the pinned release.

You should see uv 0.12.13 in the terminal.

Install Python 3.14.7

A uv-managed runtime keeps this project independent from any system Python installation. The planner can now request the same Python release every time it runs.

  • Install Python 3.14.7 through uv by running this command:
uv python install 3.14.7

What does this command do?

The command asks uv to install the specific Python release required by the planner. Your system Python remains separate from this managed runtime.

  • Inspect the Python runtimes available to uv by running this command:
uv python list

What does the list show?

The command lists the Python runtimes that uv can find. An installed entry confirms that the runtime is ready for scripts.

You should see an installed entry for Python 3.14.7.

Can't find the installed Python version?

Run the installation command again from the new terminal. Confirm that the installation finishes before listing the runtimes.

Still stuck? Help me find my uv-managed Python installation.

Create and verify the workspace

The workspace keeps your planner code beside its input file. Trusting a folder you created allows VS Code to use its normal editing features inside that folder.

  • Use your Linux file manager's folder-creation control to create ai-seo-experiment-planner on your Desktop.

You should see the new ai-seo-experiment-planner folder on your Desktop.

  • Use your Linux application launcher to start VS Code.
  • Click File in the top menu.

You will see the file and workspace commands in the menu.

  • Select Open Folder....
  • Choose the ai-seo-experiment-planner folder from your Desktop.

VS Code now shows ai-seo-experiment-planner in the left file sidebar.

  • Confirm that you trust the folder if VS Code displays a Workspace Trust prompt.

The workspace should open without Restricted Mode.

  • Use the new-file control in the left file sidebar to create planner.py.
  • Use the same control to create opportunities.csv.

Both empty files should now appear beneath ai-seo-experiment-planner.

  • Use the new-file control once more to create .gitignore.

The left file sidebar should now list planner.py, opportunities.csv, and .gitignore.

  • Select planner.py in the left file sidebar.
  • Add the workspace readiness check by pasting this code:
print("Planner workspace ready")

What does this code do?

The print statement provides a visible test for the pinned workspace. A successful message proves that uv can launch the script with the requested Python runtime.

  • Use the save control in VS Code to save planner.py.
  • Use VS Code's integrated-terminal control to open a terminal inside ai-seo-experiment-planner.

You should see the terminal panel below the editor with the workspace ready for a command.

Before you run the planner, what message do you expect its single print statement to show?

  • Run the planner with the pinned Python runtime by entering this command:
uv run --python 3.14.7 planner.py

What does this command prove?

The uv run command launches planner.py with Python 3.14.7. The output confirms that the file and runtime work together.

You will see Planner workspace ready in the terminal. You have cleared the workspace setup hurdle.

✔️ Awesome, I've got everything!

Your left file sidebar lists all three starter files. Your terminal prints Planner workspace ready.

  • Keep opportunities.csv empty for the next step.
  • Keep .gitignore empty until its generated-output rules are added later.

ⓧ I'd like to double check the full code

  • Compare your saved planner.py with this complete starter file:
print("Planner workspace ready")

The opportunities.csv file is empty. The .gitignore file is also empty.

Don't see the readiness message?

Confirm that the terminal is inside the ai-seo-experiment-planner workspace. Check that the file is named planner.py.

Compare the print statement with the full-code tab. Help me debug the workspace command.

Your reproducible Python workspace is ready. Next, you will load existing SEO opportunities into the planner.

Load and Preview SEO Opportunities

Your pinned Python 3.14.7 workspace is ready. It gives your planner a stable place to turn existing SEO data into experiments.

Exports from earlier SEO tools can use different columns. A small CSV contract catches silent column mismatches before any data reaches OpenRouter.

In this step, get ready to:
  • Populate opportunities.csv with three canonical Google Search Console rows.
  • Reject missing headers or empty required values before loading data.
  • Preview up to five numbered query-page pairs in the terminal.
Add the canonical opportunity rows

The planner needs one predictable input shape before it can process opportunities. You will start with three synthetic rows that use the six required columns.

  • Select opportunities.csv in the VS Code Explorer sidebar.
  • Add the canonical sample data by pasting this content into the empty file:
query,page,clicks,impressions,ctr,position
best trail shoes for rain,/guides/trail-shoes,34,1800,0.0189,8.4
how to clean hiking boots,/guides/clean-hiking-boots,21,1200,0.0175,7.9
waterproof hiking socks,/collections/hiking-socks,12,980,0.0122,11.3

What does this data contain?

  • Each row connects a search query to its ranking page.
  • The clicks field records visits from search results.
  • The impressions field records appearances in search results.
  • The ctr values use Search Console's range from 0 to 1.
  • The position field contains the average search-result position.
  • Save opportunities.csv.
  • Confirm the editor shows one header row followed by three data rows.

Your source data now has a consistent shape that the planner can check before loading it.

Do the CSV columns look misaligned?

  • Confirm the first row contains the six headers exactly as shown.
  • Check that every data row contains five commas.

Still stuck? Help me check the structure of my opportunities CSV.

The planner eventually creates local output files. Your ignore rules keep those generated files out of future commits.

  • Select .gitignore in the VS Code Explorer sidebar.
  • Add the local output rules by pasting this content into the file:
experiments.csv
review_queue.csv
__pycache__/

What do these rules protect?

The first two rules exclude generated experiment files. The final rule excludes Python's local cache directory.

  • Save .gitignore.
  • Confirm the editor shows three ignore rules.
Build the CSV loader

A loader turns each CSV row into a dictionary that Python can check. Shared constants keep the filename and required columns in one place.

  • Switch back to planner.py from the previous step.
  • Insert these imports and constants above the existing workspace print:
import csv
import sys

INPUT_FILE = "opportunities.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]

What does this setup control?

  • The csv module reads the input file.
  • The sys module supports a clear exit when validation fails.
  • INPUT_FILE points the loader to opportunities.csv.
  • MAX_OPPORTUNITIES limits each batch to five rows.
  • INPUT_FIELDS defines the required column names.
  • Save planner.py.
  • Confirm the setup still runs by using this command in the VS Code terminal:
uv run --python 3.14.7 planner.py

What does this check prove?

You should still see Planner workspace ready. That output confirms the imports and constants contain valid Python syntax.

  • Insert the first version of read_opportunities(path) between INPUT_FIELDS and the existing workspace print:
def read_opportunities(path):
    with open(path, newline="", encoding="utf-8") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("The input CSV has no header row.")

        missing_headers = [
            field for field in INPUT_FIELDS if field not in reader.fieldnames
        ]
        if missing_headers:
            raise ValueError(
                "Missing CSV headers: " + ", ".join(missing_headers)
            )

        opportunities = []
        for row in reader:
            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

    if not opportunities:
        raise ValueError("The input CSV contains no usable opportunity rows.")
    return opportunities

How does the loader work?

  • csv.DictReader maps each CSV row to its header names.
  • missing_headers finds required columns that are absent.
  • clean_row strips surrounding whitespace from every required value.
  • The internal id starts at 1 for the first accepted row.
  • MAX_OPPORTUNITIES stops the loader after five accepted rows.
  • Save planner.py.
  • Check the new function's syntax by running the current script again:
uv run --python 3.14.7 planner.py

What does this run confirm?

The terminal should still print Planner workspace ready. Python has now parsed the loader without finding a syntax problem.

The current loop cleans each value. It still needs to distinguish a wholly blank row from a partially empty opportunity.

  • In read_opportunities(path), find this current loop:
        for row in reader:
            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

What is missing from this loop?

This version assigns IDs to every row it receives. A blank row or a row with one missing value can still enter the list.

  • Replace the current loop with this validated version:
        for row in reader:
            if not any((row.get(field) or "").strip() for field in INPUT_FIELDS):
                continue

            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            empty_fields = [field for field, value in clean_row.items() if not value]
            if empty_fields:
                raise ValueError(
                    "A row has empty fields: " + ", ".join(empty_fields)
                )

            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

What do the new checks do?

  • The first condition skips rows where every required cell is blank.
  • empty_fields collects required columns that have no value.
  • The validation error stops a partial row before it reaches the preview.
  • The ID is assigned only after the row passes every check.
  • Save planner.py.
  • Confirm the validated loop has valid syntax by running the script again:
uv run --python 3.14.7 planner.py

What does this syntax check show?

You should still see Planner workspace ready. The validation logic is ready for the preview to call it.

Did the workspace check stop running?

  • Check that the body of read_opportunities(path) remains indented beneath the function definition.
  • Confirm the replacement loop remains indented beneath the with block.
  • Compare every opening bracket with its closing bracket.

Need another pair of eyes? Help me find the syntax problem in my CSV loader.

Preview the loaded opportunities

A preview makes the model input visible before any API request occurs. Each printed ID will later connect an experiment plan to its source row.

  • Replace the existing workspace print at the bottom of planner.py with this preview and entry point:
def preview_opportunities(opportunities):
    print(f"Loaded {len(opportunities)} opportunities:")
    for row in opportunities:
        print(f"  {row['id']}. {row['query']} -> {row['page']}")


def main():
    try:
        opportunities = read_opportunities(INPUT_FILE)
        preview_opportunities(opportunities)
    except (OSError, RuntimeError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

How does the preview run?

  • preview_opportunities(opportunities) prints the accepted row count.
  • The loop prints each internal ID beside its query-page pair.
  • main() loads the input file before starting the preview.
  • The exception handler prints a readable validation failure before exiting.
  • Save planner.py.

Before you run this, how many numbered query-page pairs do you expect the terminal to show?

  • Run the completed loader in the VS Code terminal with this command:
uv run --python 3.14.7 planner.py

What should you see?

  • The first line says Loaded 3 opportunities:.
  • Row 1 pairs best trail shoes for rain with /guides/trail-shoes.
  • Row 2 pairs how to clean hiking boots with /guides/clean-hiking-boots.
  • Row 3 pairs waterproof hiking socks with /collections/hiking-socks.

That is the input gate working. Your planner now shows every accepted opportunity before it can leave your machine.

Is the preview missing rows?

  • Confirm opportunities.csv sits beside planner.py in the ai-seo-experiment-planner folder.
  • Check that the CSV header matches every value in INPUT_FIELDS.
  • Confirm each sample row has a value in all six columns.

Still stuck? Help me debug my missing SEO opportunity preview rows.

✔️ Awesome, I've got everything!

Great. Double check that you saved all three files after seeing the three numbered opportunities.

ⓧ I'd like to double check the full code

  • Compare planner.py with this full version:
import csv
import sys

INPUT_FILE = "opportunities.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]


def read_opportunities(path):
    with open(path, newline="", encoding="utf-8") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("The input CSV has no header row.")

        missing_headers = [
            field for field in INPUT_FIELDS if field not in reader.fieldnames
        ]
        if missing_headers:
            raise ValueError(
                "Missing CSV headers: " + ", ".join(missing_headers)
            )

        opportunities = []
        for row in reader:
            if not any((row.get(field) or "").strip() for field in INPUT_FIELDS):
                continue

            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            empty_fields = [field for field, value in clean_row.items() if not value]
            if empty_fields:
                raise ValueError(
                    "A row has empty fields: " + ", ".join(empty_fields)
                )

            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

    if not opportunities:
        raise ValueError("The input CSV contains no usable opportunity rows.")
    return opportunities


def preview_opportunities(opportunities):
    print(f"Loaded {len(opportunities)} opportunities:")
    for row in opportunities:
        print(f"  {row['id']}. {row['query']} -> {row['page']}")


def main():
    try:
        opportunities = read_opportunities(INPUT_FILE)
        preview_opportunities(opportunities)
    except (OSError, RuntimeError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
  • Compare opportunities.csv with this full version:
query,page,clicks,impressions,ctr,position
best trail shoes for rain,/guides/trail-shoes,34,1800,0.0189,8.4
how to clean hiking boots,/guides/clean-hiking-boots,21,1200,0.0175,7.9
waterproof hiking socks,/collections/hiking-socks,12,980,0.0122,11.3
  • Compare .gitignore with this full version:
experiments.csv
review_queue.csv
__pycache__/

Your opportunity rows are clean and visible. Next, you will send this exact batch to OpenRouter for its first experiment plan.

Test Unstructured AI Output

Your planner now validates each opportunity before showing the rows that are ready for planning. That gives you a dependable input boundary.

A normal response from OpenRouter can sound convincing while changing labels or omitting fields. You will send one direct HTTP request so you can see why free-form text cannot safely become automated CSV rows.

In this step, get ready to:
  • Add a hidden API key prompt to the planner.
  • Send all validated opportunities to OpenRouter in one request.
  • Inspect the free-form response for missing or inconsistent structure.
Build the unstructured request

OpenRouter accepts direct API requests without an extra SDK dependency. Your planner can use Python's standard library to prepare the request while keeping every validated opportunity in one batch.

  • In planner.py, replace the import and constant section above INPUT_FIELDS with this code:
import csv
import json
from getpass import getpass
from urllib.request import Request, urlopen

API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openrouter/free"
INPUT_FILE = "opportunities.csv"
MAX_OPPORTUNITIES = 5

What does this setup add?

  • The json module converts Python dictionaries into the request body expected by the API.
  • The getpass function accepts your key without displaying its characters in the terminal.
  • The request helpers send data to API_URL through the free-model router stored in MODEL.
  • Below preview_opportunities(), add the request payload by pasting this function:
def build_payload(opportunities):
    return {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Turn each supplied SEO opportunity into exactly one measurable "
                    "experiment. Preserve every integer id. Base plans only on the "
                    "supplied metrics, do not invent current results, and treat every "
                    "recommendation as a hypothesis rather than a guarantee."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(
                    {"opportunities": opportunities}, ensure_ascii=False
                ),
            },
        ],
    }

What does this payload do?

  • The model value routes the request through the Free Models Router.
  • The system message asks for one measurable experiment per opportunity. It also frames every recommendation as a hypothesis.
  • The user message serializes every previewed row into one batch. This keeps the source IDs attached to their metrics.
  • Save planner.py.
  • Confirm the existing preview still runs by using this command in the VS Code terminal:
uv run --python 3.14.7 planner.py

You should still see three numbered query and page pairs. This confirms the new imports and payload function load successfully without breaking your validated preview.

Does the preview stop running?

Check that all four import lines remain at the top of planner.py. Make sure build_payload() starts after the closing line of preview_opportunities().

Still stuck? Help me check the new imports and build_payload() function.

Send the request

The payload still needs a transport layer. The next function converts it to bytes and sends one POST request with your key in the authorization header.

  • Below build_payload(), add the API call by pasting this function:
def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    request = Request(
        API_URL,
        data=request_body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))

How does the request work?

  • The payload becomes UTF-8 bytes so it can travel in the request body.
  • The authorization header carries the API key for this request. The content-type header identifies the body as JSON.
  • The response is decoded into a Python dictionary. This makes its message content available to the planner.
  • Replace the current main() function with this version:
def main():
    opportunities = read_opportunities(INPUT_FILE)
    preview_opportunities(opportunities)

    api_key = getpass("Paste your OpenRouter API key: ").strip()
    if not api_key:
        raise ValueError("An OpenRouter API key is required.")

    response = call_openrouter(build_payload(opportunities), api_key)
    print(response["choices"][0]["message"]["content"])

What does the new main flow do?

  • The planner preserves the preview before asking for your key. You can inspect the exact source rows before any request leaves your computer.
  • The hidden prompt keeps the key out of the visible terminal history. The script holds it in memory for the current run.
  • The final line prints the assistant message returned by OpenRouter.
  • Save planner.py.

Entering a credential into a blank-looking prompt can feel uncertain. The prompt hides every character you paste, while the script keeps the key in memory and never writes it to a file.

Before you run this, do you think the model will use a predictable set of fields when your program has only described the task in words?

  • Start the unstructured request by running this command in the VS Code terminal:
uv run --python 3.14.7 planner.py

What happens during this run?

The planner first shows the three validated opportunity rows. It then waits at the hidden key prompt before sending the batch to OpenRouter.

  • Paste your OpenRouter API key into the hidden prompt.
  • Press Enter to submit the request.

You will see a readable experiment plan after the request finishes. Its headings and layout are chosen by the selected model, so the response may use prose, lists, or differently named sections.

That shortfall is intentional. The response sounds useful to a person, but your program has no dependable field structure to read.

Does the request fail?

Confirm that your internet connection is active. Run the script again if you accidentally submitted an empty key.

If authentication still fails, use a valid OpenRouter API key and keep it out of planner.py.

Need help diagnosing the request? Help me debug my direct OpenRouter request without exposing my API key.

Inspect the free-form plan

Automated export needs one predictable value for every destination column. Free-form language gives the model control over how those values are named and arranged.

  • Compare the response with the required experiment fields id, hypothesis, action, success_metric, measurement_plan, and priority.
  • Check whether every previewed ID has exactly one clearly identifiable experiment.
  • Confirm that the three validated opportunity rows appear before the model response.

You should see three numbered query and page pairs followed by a readable plan. The response has no program-enforced guarantee that every required field appears exactly once.

Why can't this become CSV rows safely?

The payload has no response_format constraint. OpenRouter therefore returns a normal assistant message whose presentation is controlled by the selected model.

A parser would need to guess which heading belongs to each CSV column. Missing labels or duplicated experiments could silently corrupt the exported backlog.

✔️ Awesome, I've got everything!

Great. Your planner previews the validated rows, asks for the key without displaying it, and prints one free-form response.

ⓧ I'd like to double check the full code

Compare your saved planner.py with this complete version.

import csv
import json
from getpass import getpass
from urllib.request import Request, urlopen

API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openrouter/free"
INPUT_FILE = "opportunities.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]


def read_opportunities(path):
    with open(path, newline="", encoding="utf-8") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("The input CSV has no header row.")

        missing_headers = [
            field for field in INPUT_FIELDS if field not in reader.fieldnames
        ]
        if missing_headers:
            raise ValueError(
                "Missing CSV headers: " + ", ".join(missing_headers)
            )

        opportunities = []
        for row in reader:
            if not any((row.get(field) or "").strip() for field in INPUT_FIELDS):
                continue

            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            empty_fields = [field for field, value in clean_row.items() if not value]
            if empty_fields:
                raise ValueError(
                    "A row has empty fields: " + ", ".join(empty_fields)
                )

            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

    if not opportunities:
        raise ValueError("The input CSV contains no usable opportunity rows.")
    return opportunities


def preview_opportunities(opportunities):
    print(f"Loaded {len(opportunities)} opportunities:")
    for row in opportunities:
        print(f"  {row['id']}. {row['query']} -> {row['page']}")


def build_payload(opportunities):
    return {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Turn each supplied SEO opportunity into exactly one measurable "
                    "experiment. Preserve every integer id. Base plans only on the "
                    "supplied metrics, do not invent current results, and treat every "
                    "recommendation as a hypothesis rather than a guarantee."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(
                    {"opportunities": opportunities}, ensure_ascii=False
                ),
            },
        ],
    }


def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    request = Request(
        API_URL,
        data=request_body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))


def main():
    opportunities = read_opportunities(INPUT_FILE)
    preview_opportunities(opportunities)

    api_key = getpass("Paste your OpenRouter API key: ").strip()
    if not api_key:
        raise ValueError("An OpenRouter API key is required.")

    response = call_openrouter(build_payload(opportunities), api_key)
    print(response["choices"][0]["message"]["content"])


if __name__ == "__main__":
    main()

You have now seen the automation gap firsthand. Next, you will give the response a strict structure and reject any plan that breaks the contract.

Constrain and Validate AI Plans

Your previous run sent every opportunity to OpenRouter in one batch. The response was readable, but its structure belonged to the selected model.

A CSV exporter needs stable field names for every source ID. This step adds a strict response contract and local checks before any plan can move forward.

In this step, get ready to:
  • Define the exact structure required for every experiment.
  • Restrict routing to providers that support the requested parameters.
  • Validate every returned plan against its source opportunity.
Define the experiment contract

A JSON Schema describes the shape that each response must follow. Your schema fixes the six plan fields and limits priority to three accepted values.

  • In planner.py, replace the current import block with this expanded import group:
import csv
import json
import sys
from getpass import getpass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

Why expand the imports?

  • The HTTPError and URLError classes let the planner distinguish API failures from network failures.
  • The sys module lets the program report failures through standard error before exiting.
  • Save planner.py.
  • Confirm that VS Code shows no new import warnings in planner.py.

Seeing an import warning?

Check that each import appears on its own line. Keep HTTPError and URLError inside the same import statement.

Still stuck? Help me check my Python import block.

The schema has an outer response object and an inner experiment object. You will assemble the dictionary from two consecutive pieces so each layer stays easy to inspect.

  • Below INPUT_FIELDS, paste the first part of the contract:
PLAN_FIELDS = [
    "id",
    "hypothesis",
    "action",
    "success_metric",
    "measurement_plan",
    "priority",
]
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}

RESPONSE_SCHEMA = {
    "type": "object",
    "properties": {
        "experiments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "description": "The unchanged integer ID from the source opportunity.",
                    },
                    "hypothesis": {
                        "type": "string",
                        "description": "A testable explanation of what may improve.",
                    },

What does the first schema section define?

  • The PLAN_FIELDS list records the only keys accepted in an experiment.
  • The PRIORITY_ORDER dictionary records the accepted priority values for validation now and sorting later.
  • The outer schema requires an object containing an experiments array.
  • Paste the second part directly below the closing brace for hypothesis to complete the dictionary:
                    "action": {
                        "type": "string",
                        "description": "One concrete SEO change to test.",
                    },
                    "success_metric": {
                        "type": "string",
                        "description": "The Search Console metric used to judge the test.",
                    },
                    "measurement_plan": {
                        "type": "string",
                        "description": "How to compare the result with the current baseline.",
                    },
                    "priority": {
                        "type": "string",
                        "enum": ["high", "medium", "low"],
                        "description": "Relative review priority for this batch.",
                    },
                },
                "required": PLAN_FIELDS,
                "additionalProperties": False,
            },
        }
    },
    "required": ["experiments"],
    "additionalProperties": False,
}

How does the completed contract stay strict?

  • The inner required value requires every field listed in PLAN_FIELDS.
  • The two additionalProperties settings reject unexpected keys at both schema levels.
  • The enum accepts only high, medium, or low as a priority.
  • Save planner.py.
  • Confirm that VS Code shows no unmatched braces in RESPONSE_SCHEMA.

Seeing an unmatched-brace warning?

Check that the second snippet begins at the same indentation level as hypothesis. Confirm that the final closing brace aligns with RESPONSE_SCHEMA.

Need another pair of eyes? Help me find the unmatched brace in my response schema.

Request schema-constrained output

The schema only influences the response after it becomes part of the request. Capability filtering also keeps the free router focused on endpoints that support the requested parameters.

  • In build_payload(), find the closing bracket for the messages list.
  • Add these request entries directly below that closing bracket:
        "provider": {"require_parameters": True},
        "response_format": {
            "type": "json_schema",
            "json_schema": {
                "name": "seo_experiment_plan",
                "strict": True,
                "schema": RESPONSE_SCHEMA,
            },
        },

What changes in the request?

  • The provider entry requires support for every requested parameter.
  • The response_format entry sends RESPONSE_SCHEMA under the name seo_experiment_plan.
  • The strict value asks the selected endpoint to follow the supplied schema.
  • Replace the existing call_openrouter() function with this version:
def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    request = Request(
        API_URL,
        data=request_body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    try:
        with urlopen(request, timeout=60) as response:
            return json.loads(response.read().decode("utf-8"))
    except HTTPError as error:
        details = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(
            f"OpenRouter returned HTTP {error.code}: {details}"
        ) from error
    except URLError as error:
        raise RuntimeError(f"Network error: {error.reason}") from error

What does the request function protect?

  • The request body contains the schema-constrained payload encoded as UTF-8.
  • The authorization header uses the key held in memory by api_key.
  • The two exception handlers turn HTTP and network failures into useful runtime errors.
  • Save planner.py.

Before you run the planner, what structure do you expect inside the model content now?

  • Send the constrained request by running this command:
uv run --python 3.14.7 planner.py

What does this run check?

This run uses the pinned Python runtime to load the opportunities and send the new payload. The API key remains hidden because getpass() does not echo the pasted value.

  • Paste your OpenRouter API key at the hidden prompt.
  • Press Enter to submit the request.

You should see the three opportunity previews followed by content containing an experiments array. Each experiment should contain the six fields from your contract.

The constrained request did not return content?

Check that provider and response_format sit inside the dictionary returned by build_payload().

If the request fails, inspect the reported HTTP or network details without pasting your key anywhere. Help me debug my constrained OpenRouter request safely.

Validate every returned plan

A schema-constrained response still crosses an external boundary. Local validation protects the CSV workflow from missing records, duplicated IDs, empty text, and unexpected fields.

  • Add the first part of parse_plans() below call_openrouter() and above main():
def parse_plans(response, opportunities):
    try:
        model_used = response["model"]
        content = response["choices"][0]["message"]["content"]
        parsed = json.loads(content)
        plans = parsed["experiments"]
    except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
        raise ValueError("The API response did not contain valid experiment JSON.") from error

    if not isinstance(model_used, str) or not model_used:
        raise ValueError("The API response did not identify the selected model.")
    if not isinstance(plans, list):
        raise ValueError("The experiments value must be a list.")

What does the parser open first?

  • The parser captures the selected model from response["model"] for attribution.
  • It decodes the message content into a Python object and extracts the experiments list.
  • It rejects responses that omit the expected response path or contain invalid JSON.
  • Save planner.py.
  • Confirm that VS Code shows no syntax errors around the new function.
  • Inside parse_plans(), add the remaining validation directly below the list check:
    expected_keys = set(PLAN_FIELDS)
    expected_ids = {row["id"] for row in opportunities}
    actual_ids = set()

    for plan in plans:
        if not isinstance(plan, dict) or set(plan) != expected_keys:
            raise ValueError("An experiment has missing or unexpected fields.")
        if not isinstance(plan["id"], int) or isinstance(plan["id"], bool):
            raise ValueError("Every experiment id must be an integer.")
        for field in PLAN_FIELDS[1:]:
            if not isinstance(plan[field], str) or not plan[field].strip():
                raise ValueError(f"Experiment field {field} must be non-empty text.")
        if plan["priority"] not in PRIORITY_ORDER:
            raise ValueError("Priority must be high, medium, or low.")
        actual_ids.add(plan["id"])

    if len(plans) != len(opportunities) or actual_ids != expected_ids:
        raise ValueError(
            "The response must contain exactly one experiment for every source id."
        )

    return plans, model_used

Which problems does local validation catch?

  • Exact key comparison rejects missing fields and unexpected fields.
  • Type checks reject Boolean values masquerading as integer IDs and blank plan text.
  • Set comparison rejects duplicated IDs and missing source IDs.
  • The final count check requires one experiment for every previewed opportunity.
  • Replace the existing main() function and script entry point with this final version:
def main():
    try:
        opportunities = read_opportunities(INPUT_FILE)
        preview_opportunities(opportunities)

        api_key = getpass("Paste your OpenRouter API key: ").strip()
        if not api_key:
            raise ValueError("An OpenRouter API key is required.")

        response = call_openrouter(build_payload(opportunities), api_key)
        plans, model_used = parse_plans(response, opportunities)
        print(f"Validated {len(plans)} experiments from {model_used}.")
    except (OSError, RuntimeError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

What changes in the main workflow?

  • The planner now passes the response through parse_plans() before reporting success.
  • The success message includes the number of accepted experiments and the selected model.
  • Validation and request failures exit through one error path without printing the API key.
  • Save planner.py.

Before you run the final check, do you expect to see the raw model content or a validation summary?

  • Test the complete validation path by running this command:
uv run --python 3.14.7 planner.py

Why run the same command again?

The command now exercises the schema request and the local parser in one run. A success message appears only after every returned experiment passes the checks.

  • Paste your OpenRouter API key at the hidden prompt.
  • Press Enter to complete the request.

You should see three numbered query and page pairs followed by a message confirming three validated experiments. The same message names the actual model selected by OpenRouter.

That is the contract working. Your planner now accepts one complete experiment for every source ID before the data can reach a CSV.

Seeing a validation error?

Check that PLAN_FIELDS contains all six field names in the same spelling used by RESPONSE_SCHEMA.

Check that the final ID comparison remains below the validation loop inside parse_plans(). Help me trace my experiment validation error.

✔️ Awesome, I've got everything!

Great. Double check that you saved planner.py and saw one validated experiment for every previewed opportunity.

ⓧ I'd like to double check the full code

import csv
import json
import sys
from getpass import getpass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openrouter/free"
INPUT_FILE = "opportunities.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]
PLAN_FIELDS = [
    "id",
    "hypothesis",
    "action",
    "success_metric",
    "measurement_plan",
    "priority",
]
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}

RESPONSE_SCHEMA = {
    "type": "object",
    "properties": {
        "experiments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "description": "The unchanged integer ID from the source opportunity.",
                    },
                    "hypothesis": {
                        "type": "string",
                        "description": "A testable explanation of what may improve.",
                    },
                    "action": {
                        "type": "string",
                        "description": "One concrete SEO change to test.",
                    },
                    "success_metric": {
                        "type": "string",
                        "description": "The Search Console metric used to judge the test.",
                    },
                    "measurement_plan": {
                        "type": "string",
                        "description": "How to compare the result with the current baseline.",
                    },
                    "priority": {
                        "type": "string",
                        "enum": ["high", "medium", "low"],
                        "description": "Relative review priority for this batch.",
                    },
                },
                "required": PLAN_FIELDS,
                "additionalProperties": False,
            },
        }
    },
    "required": ["experiments"],
    "additionalProperties": False,
}


def read_opportunities(path):
    with open(path, newline="", encoding="utf-8") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("The input CSV has no header row.")

        missing_headers = [
            field for field in INPUT_FIELDS if field not in reader.fieldnames
        ]
        if missing_headers:
            raise ValueError(
                "Missing CSV headers: " + ", ".join(missing_headers)
            )

        opportunities = []
        for row in reader:
            if not any((row.get(field) or "").strip() for field in INPUT_FIELDS):
                continue

            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            empty_fields = [field for field, value in clean_row.items() if not value]
            if empty_fields:
                raise ValueError(
                    "A row has empty fields: " + ", ".join(empty_fields)
                )

            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

    if not opportunities:
        raise ValueError("The input CSV contains no usable opportunity rows.")
    return opportunities


def preview_opportunities(opportunities):
    print(f"Loaded {len(opportunities)} opportunities:")
    for row in opportunities:
        print(f"  {row['id']}. {row['query']} -> {row['page']}")


def build_payload(opportunities):
    return {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Turn each supplied SEO opportunity into exactly one measurable "
                    "experiment. Preserve every integer id. Base plans only on the "
                    "supplied metrics, do not invent current results, and treat every "
                    "recommendation as a hypothesis rather than a guarantee."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(
                    {"opportunities": opportunities}, ensure_ascii=False
                ),
            },
        ],
        "provider": {"require_parameters": True},
        "response_format": {
            "type": "json_schema",
            "json_schema": {
                "name": "seo_experiment_plan",
                "strict": True,
                "schema": RESPONSE_SCHEMA,
            },
        },
    }


def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    request = Request(
        API_URL,
        data=request_body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    try:
        with urlopen(request, timeout=60) as response:
            return json.loads(response.read().decode("utf-8"))
    except HTTPError as error:
        details = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(
            f"OpenRouter returned HTTP {error.code}: {details}"
        ) from error
    except URLError as error:
        raise RuntimeError(f"Network error: {error.reason}") from error


def parse_plans(response, opportunities):
    try:
        model_used = response["model"]
        content = response["choices"][0]["message"]["content"]
        parsed = json.loads(content)
        plans = parsed["experiments"]
    except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
        raise ValueError("The API response did not contain valid experiment JSON.") from error

    if not isinstance(model_used, str) or not model_used:
        raise ValueError("The API response did not identify the selected model.")
    if not isinstance(plans, list):
        raise ValueError("The experiments value must be a list.")

    expected_keys = set(PLAN_FIELDS)
    expected_ids = {row["id"] for row in opportunities}
    actual_ids = set()

    for plan in plans:
        if not isinstance(plan, dict) or set(plan) != expected_keys:
            raise ValueError("An experiment has missing or unexpected fields.")
        if not isinstance(plan["id"], int) or isinstance(plan["id"], bool):
            raise ValueError("Every experiment id must be an integer.")
        for field in PLAN_FIELDS[1:]:
            if not isinstance(plan[field], str) or not plan[field].strip():
                raise ValueError(f"Experiment field {field} must be non-empty text.")
        if plan["priority"] not in PRIORITY_ORDER:
            raise ValueError("Priority must be high, medium, or low.")
        actual_ids.add(plan["id"])

    if len(plans) != len(opportunities) or actual_ids != expected_ids:
        raise ValueError(
            "The response must contain exactly one experiment for every source id."
        )

    return plans, model_used


def main():
    try:
        opportunities = read_opportunities(INPUT_FILE)
        preview_opportunities(opportunities)

        api_key = getpass("Paste your OpenRouter API key: ").strip()
        if not api_key:
            raise ValueError("An OpenRouter API key is required.")

        response = call_openrouter(build_payload(opportunities), api_key)
        plans, model_used = parse_plans(response, opportunities)
        print(f"Validated {len(plans)} experiments from {model_used}.")
    except (OSError, RuntimeError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

Your planner now turns a routed model response into a checked set of experiment objects. Next, you will make the request resilient and export those plans as a prioritized backlog.

Export a Resilient Experiment Backlog

Your planner now turns every source ID into a validated experiment. Those plans still disappear when the terminal closes.

A dependable workflow must recover from one rate-limited OpenRouter request. It also needs a sorted CSV that preserves the source metrics plus model attribution.

In this step, get ready to:
  • Add one retry for an HTTP 429 response.
  • Build enriched rows containing source metrics plus model attribution.
  • Export the rows in priority order to experiments.csv.
Handle transient API failures

A rate limit is a temporary API failure that your planner can retry. Python makes HTTPError a subclass of URLError, so the more specific exception must come first.

A numeric Retry-After header provides the delay. The planner falls back to two seconds when that value is unavailable.

  • In planner.py, find this current import block:
import csv
import json
import sys
from getpass import getpass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

Why Find This Import Block?

This block already imports the request exceptions used by the planner. The retry delay needs one additional standard-library module.

  • Replace the import block with this updated version:
import csv
import json
import sys
import time
from getpass import getpass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

What Changed?

The time module provides time.sleep(). The planner uses that pause before its single retry.

  • In planner.py, select the complete call_openrouter() function.
  • Replace the selected function with this first part:
def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    for attempt in range(2):
        request = Request(
            API_URL,
            data=request_body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )

        try:
            with urlopen(request, timeout=60) as response:
                return json.loads(response.read().decode("utf-8"))

How Does the Request Loop Work?

  • The request body is encoded once because both attempts send the same payload.
  • The loop allows one initial request plus one retry.
  • The request object is rebuilt for each attempt before the planner waits for a response.
  • Paste these error handlers directly below the successful response return:
        except HTTPError as error:
            if error.code == 429 and attempt == 0:
                retry_after = error.headers.get("Retry-After")
                delay = (
                    int(retry_after)
                    if retry_after and retry_after.isdigit()
                    else 2
                )
                print(f"Rate limited. Retrying in {delay} seconds...")
                time.sleep(delay)
                continue

            details = error.read().decode("utf-8", errors="replace")
            raise RuntimeError(
                f"OpenRouter returned HTTP {error.code}: {details}"
            ) from error
        except URLError as error:
            raise RuntimeError(f"Network error: {error.reason}") from error

    raise RuntimeError("OpenRouter request failed after one retry.")

How Does the Retry Stay Controlled?

  • The 429 branch only retries when the first attempt is rate limited.
  • A numeric Retry-After value controls the wait when OpenRouter supplies one.
  • Other HTTP failures surface their response details without including the API key.
  • Network failures become a concise runtime error through the later URLError handler.
  • Save planner.py.

Your API key stays hidden while you type it. The planner keeps it in memory for the current run only.

Before you run this check, do you expect a normal successful request to enter the retry branch?

  • Check the updated request path by running:
uv run --python 3.14.7 planner.py

What Does This Check Prove?

This command runs the planner with the pinned runtime. A normal response passes through the new retry loop before reaching the existing plan validation.

You'll see the three opportunity previews followed by the validated experiment count plus the selected model. A first-attempt rate limit prints the retry delay before the planner tries once more.

Does the Request Keep Failing?

  • Check that your internet connection is available before trying another run.
  • Wait before retrying manually if both allowed attempts are rate limited.
  • Enter the OpenRouter API key again if the hidden prompt received an empty value.

Still stuck? Help me diagnose why my resilient OpenRouter request is failing.

Build the prioritized CSV export

A validated plan contains the generated experiment fields. The export function uses its id to recover the original opportunity metrics.

Each enriched row also records model_used. That attribution matters because the free router can select a different model on another run.

  • In planner.py, select the constants from API_URL through PRIORITY_ORDER.
  • Replace the selected constants with this updated block:
API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openrouter/free"
INPUT_FILE = "opportunities.csv"
OUTPUT_FILE = "experiments.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]
PLAN_FIELDS = [
    "id",
    "hypothesis",
    "action",
    "success_metric",
    "measurement_plan",
    "priority",
]
OUTPUT_FIELDS = ["id", *INPUT_FIELDS, *PLAN_FIELDS[1:], "model_used"]
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}

What Do the Export Constants Control?

  • OUTPUT_FILE gives the generated backlog one consistent path.
  • OUTPUT_FIELDS preserves the source columns before the generated plan columns.
  • PRIORITY_ORDER maps each priority to its intended sort position.
  • Add this write_output() function below parse_plans() in planner.py.
def write_output(opportunities, plans, model_used, path):
    plans_by_id = {plan["id"]: plan for plan in plans}
    enriched_rows = []

    for opportunity in opportunities:
        plan = plans_by_id[opportunity["id"]]
        enriched_rows.append(
            {**opportunity, **plan, "model_used": model_used}
        )

    enriched_rows.sort(
        key=lambda row: (PRIORITY_ORDER[row["priority"]], row["id"])
    )

    with open(path, "w", newline="", encoding="utf-8") as target:
        writer = csv.DictWriter(target, fieldnames=OUTPUT_FIELDS)
        writer.writeheader()
        writer.writerows(enriched_rows)

What Does the Export Function Do?

  • plans_by_id makes each validated experiment available through its source ID.
  • The merge preserves the original metrics while adding the generated plan plus model attribution.
  • The sort key places high priority first. ID order keeps rows stable within the same priority.
  • csv.DictWriter writes every dictionary using the exact order in OUTPUT_FIELDS.
  • Save planner.py.
  • Confirm the new export code loads successfully by running:
uv run --python 3.14.7 planner.py

What Does This Run Confirm?

The planner loads the new constants plus write_output() as valid code. It still reaches the validated experiment count after the API response passes every check.

You'll see one validated experiment for every previewed ID. That confirms the export helper is ready to receive the accepted plans.

Seeing a Syntax Error Near the Export Function?

  • Check that write_output() starts at the left edge of the file.
  • Check that every line inside the function uses consistent four-space indentation.
  • Confirm that OUTPUT_FIELDS appears after the closing bracket of PLAN_FIELDS.

Need a second pair of eyes? Help me fix my export function.

Connect and inspect the export

The final link passes the validated plans into write_output(). The success message then reports the output path plus row count.

  • In main(), find these existing lines:
        plans, model_used = parse_plans(response, opportunities)
        print(f"Validated {len(plans)} experiments from {model_used}.")

Where Does the Export Belong?

These lines run only after the response has passed local validation. Writing the file here prevents rejected records from reaching the backlog.

  • Replace those lines with this completed success path:
        plans, model_used = parse_plans(response, opportunities)
        print(f"Validated {len(plans)} experiments from {model_used}.")

        write_output(opportunities, plans, model_used, OUTPUT_FILE)
        print(f"Saved {len(plans)} prioritized rows to {OUTPUT_FILE}.")

What Does the Completed Path Report?

The first message identifies the selected model plus validated count. The second confirms that the same accepted records reached experiments.csv.

  • Save planner.py.

✔️ Awesome, I've got everything!

Great. Confirm that planner.py is saved before running the completed planner.

ⓧ I'd like to double check the full code

import csv
import json
import sys
import time
from getpass import getpass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openrouter/free"
INPUT_FILE = "opportunities.csv"
OUTPUT_FILE = "experiments.csv"
MAX_OPPORTUNITIES = 5

INPUT_FIELDS = [
    "query",
    "page",
    "clicks",
    "impressions",
    "ctr",
    "position",
]
PLAN_FIELDS = [
    "id",
    "hypothesis",
    "action",
    "success_metric",
    "measurement_plan",
    "priority",
]
OUTPUT_FIELDS = ["id", *INPUT_FIELDS, *PLAN_FIELDS[1:], "model_used"]
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}

RESPONSE_SCHEMA = {
    "type": "object",
    "properties": {
        "experiments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "description": "The unchanged integer ID from the source opportunity.",
                    },
                    "hypothesis": {
                        "type": "string",
                        "description": "A testable explanation of what may improve.",
                    },
                    "action": {
                        "type": "string",
                        "description": "One concrete SEO change to test.",
                    },
                    "success_metric": {
                        "type": "string",
                        "description": "The Search Console metric used to judge the test.",
                    },
                    "measurement_plan": {
                        "type": "string",
                        "description": "How to compare the result with the current baseline.",
                    },
                    "priority": {
                        "type": "string",
                        "enum": ["high", "medium", "low"],
                        "description": "Relative review priority for this batch.",
                    },
                },
                "required": PLAN_FIELDS,
                "additionalProperties": False,
            },
        }
    },
    "required": ["experiments"],
    "additionalProperties": False,
}


def read_opportunities(path):
    with open(path, newline="", encoding="utf-8") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("The input CSV has no header row.")

        missing_headers = [
            field for field in INPUT_FIELDS if field not in reader.fieldnames
        ]
        if missing_headers:
            raise ValueError(
                "Missing CSV headers: " + ", ".join(missing_headers)
            )

        opportunities = []
        for row in reader:
            if not any((row.get(field) or "").strip() for field in INPUT_FIELDS):
                continue

            clean_row = {
                field: (row.get(field) or "").strip() for field in INPUT_FIELDS
            }
            empty_fields = [field for field, value in clean_row.items() if not value]
            if empty_fields:
                raise ValueError(
                    "A row has empty fields: " + ", ".join(empty_fields)
                )

            clean_row["id"] = len(opportunities) + 1
            opportunities.append(clean_row)
            if len(opportunities) == MAX_OPPORTUNITIES:
                break

    if not opportunities:
        raise ValueError("The input CSV contains no usable opportunity rows.")
    return opportunities


def preview_opportunities(opportunities):
    print(f"Loaded {len(opportunities)} opportunities:")
    for row in opportunities:
        print(f"  {row['id']}. {row['query']} -> {row['page']}")


def build_payload(opportunities):
    return {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Turn each supplied SEO opportunity into exactly one measurable "
                    "experiment. Preserve every integer id. Base plans only on the "
                    "supplied metrics, do not invent current results, and treat every "
                    "recommendation as a hypothesis rather than a guarantee."
                ),
            },
            {
                "role": "user",
                "content": json.dumps(
                    {"opportunities": opportunities}, ensure_ascii=False
                ),
            },
        ],
        "provider": {"require_parameters": True},
        "response_format": {
            "type": "json_schema",
            "json_schema": {
                "name": "seo_experiment_plan",
                "strict": True,
                "schema": RESPONSE_SCHEMA,
            },
        },
    }


def call_openrouter(payload, api_key):
    request_body = json.dumps(payload).encode("utf-8")

    for attempt in range(2):
        request = Request(
            API_URL,
            data=request_body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )

        try:
            with urlopen(request, timeout=60) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            if error.code == 429 and attempt == 0:
                retry_after = error.headers.get("Retry-After")
                delay = (
                    int(retry_after)
                    if retry_after and retry_after.isdigit()
                    else 2
                )
                print(f"Rate limited. Retrying in {delay} seconds...")
                time.sleep(delay)
                continue

            details = error.read().decode("utf-8", errors="replace")
            raise RuntimeError(
                f"OpenRouter returned HTTP {error.code}: {details}"
            ) from error
        except URLError as error:
            raise RuntimeError(f"Network error: {error.reason}") from error

    raise RuntimeError("OpenRouter request failed after one retry.")


def parse_plans(response, opportunities):
    try:
        model_used = response["model"]
        content = response["choices"][0]["message"]["content"]
        parsed = json.loads(content)
        plans = parsed["experiments"]
    except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
        raise ValueError("The API response did not contain valid experiment JSON.") from error

    if not isinstance(model_used, str) or not model_used:
        raise ValueError("The API response did not identify the selected model.")
    if not isinstance(plans, list):
        raise ValueError("The experiments value must be a list.")

    expected_keys = set(PLAN_FIELDS)
    expected_ids = {row["id"] for row in opportunities}
    actual_ids = set()

    for plan in plans:
        if not isinstance(plan, dict) or set(plan) != expected_keys:
            raise ValueError("An experiment has missing or unexpected fields.")
        if not isinstance(plan["id"], int) or isinstance(plan["id"], bool):
            raise ValueError("Every experiment id must be an integer.")
        for field in PLAN_FIELDS[1:]:
            if not isinstance(plan[field], str) or not plan[field].strip():
                raise ValueError(f"Experiment field {field} must be non-empty text.")
        if plan["priority"] not in PRIORITY_ORDER:
            raise ValueError("Priority must be high, medium, or low.")
        actual_ids.add(plan["id"])

    if len(plans) != len(opportunities) or actual_ids != expected_ids:
        raise ValueError(
            "The response must contain exactly one experiment for every source id."
        )

    return plans, model_used


def write_output(opportunities, plans, model_used, path):
    plans_by_id = {plan["id"]: plan for plan in plans}
    enriched_rows = []

    for opportunity in opportunities:
        plan = plans_by_id[opportunity["id"]]
        enriched_rows.append(
            {**opportunity, **plan, "model_used": model_used}
        )

    enriched_rows.sort(
        key=lambda row: (PRIORITY_ORDER[row["priority"]], row["id"])
    )

    with open(path, "w", newline="", encoding="utf-8") as target:
        writer = csv.DictWriter(target, fieldnames=OUTPUT_FIELDS)
        writer.writeheader()
        writer.writerows(enriched_rows)


def main():
    try:
        opportunities = read_opportunities(INPUT_FILE)
        preview_opportunities(opportunities)

        api_key = getpass("Paste your OpenRouter API key: ").strip()
        if not api_key:
            raise ValueError("An OpenRouter API key is required.")

        response = call_openrouter(build_payload(opportunities), api_key)
        plans, model_used = parse_plans(response, opportunities)
        print(f"Validated {len(plans)} experiments from {model_used}.")

        write_output(opportunities, plans, model_used, OUTPUT_FILE)
        print(f"Saved {len(plans)} prioritized rows to {OUTPUT_FILE}.")
    except (OSError, RuntimeError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

How to Use the Full-File Reference

Use the reference to compare function order plus indentation. Your existing .gitignore already excludes generated CSV files plus Python cache folders.

The completed run asks for your OpenRouter API key through the same hidden prompt. Your input stays off screen.

Before you run the completed planner, which priority do you expect to appear first in the exported backlog?

  • Create the prioritized backlog by running:
uv run --python 3.14.7 planner.py

What Does the Completed Run Do?

The planner previews the source opportunities before making one request. It validates every returned plan before writing any rows.

A successful run reports the selected model. It also reports the path plus number of prioritized rows saved.

You'll see three validated experiments followed by a message confirming three prioritized rows in experiments.csv.

  • In the Explorer sidebar of Visual Studio Code, select experiments.csv.
  • Check that the header starts with the original source fields.
  • Confirm that the generated experiment fields follow the source metrics.
  • Scan the priority column for high-to-low ordering.
  • Confirm that every model_used cell identifies the model returned for this run.

The exact experiment wording can vary because the free router selects from available models. The columns plus validated row count remain your deterministic checks.

Keep Human Review in the Loop

Every exported recommendation is a hypothesis. Review each action against business context before changing a live page.

The priority order focuses attention. Your judgment decides which experiment is worth running.

Can't Find experiments.csv?

  • Check the terminal for a validation or network error before the saved-row message.
  • Confirm that write_output() is called after parse_plans() in main().
  • Refresh the Explorer sidebar if the successful save message is visible.

Need help tracing the missing file? Help me debug why experiments.csv was not created.

That's the workflow complete. Your planner now survives one rate limit and turns validated AI output into a reviewable experiment backlog.

Secret mission

Create a High-Priority Review Queue

A prioritized backlog can still leave the next decision buried among lower-priority experiments. Reuse the validated rows to create a separate review queue containing only high-priority work, without making another OpenRouter request in the same run.

Clean Up Your Resources

Clean Up Your Resources

Your planner leaves no server or background cloud resource running. Choose whether to keep the no-cost local workspace, close it for now, or delete it.

Resources you used:

  • Local ai-seo-experiment-planner workspace containing planner.py, opportunities.csv, and .gitignore.
  • Generated experiments.csv experiment backlog.
  • Generated review_queue.csv high-priority review queue.

Keep everything running

No action is needed. Choose this option if you want to run the planner with new opportunity rows later.

  • Keep the ai-seo-experiment-planner folder on your machine.
  • Keep uv 0.12.13 installed for future Python work.
  • Keep Python 3.14.7 available through uv.

Your validated backlog and high-priority queue remain ready for your next Search Console export.

Pause - I'll come back to this later

Closing the workspace pauses your work because the planner runs only when you launch it. No OpenRouter request occurs while the script is stopped.

  • Close Visual Studio Code.
  • Leave the ai-seo-experiment-planner folder on your machine.

Your source files and both generated output files remain available when you return.

Delete - I don't want to use this again

Deleting the folder is permanent, but it only removes this project's local files. uv and Python remain installed for other projects.

  • Close Visual Studio Code.
  • Search for ai-seo-experiment-planner in your Linux file manager.
  • Select the matching ai-seo-experiment-planner folder.
  • Use your file manager's permanent-delete action.
  • Search for ai-seo-experiment-planner again.

You should see no matching project folder. This confirms that the source files and both generated output files are gone.

The project never wrote your OpenRouter API key to disk. A shared or exposed key still needs separate revocation in your OpenRouter account.

Nice Work!

Nice Work!

You did it! You built a Python workflow that turns existing SEO opportunities into validated experiment backlogs with OpenRouter.

You've learned how to:

  • Built a canonical CSV input contract that validates Google Search Console opportunity rows before they reach the model.
  • Experienced why free-form model text is unreliable for automation. Replaced it with structured outputs backed by a strict JSON Schema. Added local validation for a one-to-one match with source IDs.
  • Added resilient HTTP error handling that retries one rate-limited request. Network failures surface without exposing the key. Exported priority-sorted experiments with model attribution to experiments.csv.
  • Completed the optional Secret Mission by generating review_queue.csv from the existing validated rows. The focused queue contains only high-priority experiments without a second OpenRouter request.

Ready to quiz yourself?