Index Ai Academy
Phase 0 · Tools · Talking to APIs

Capstone: ad-copy reviewer

Combine files, functions, JSON and an LLM call into one small tool.

About 40 minutes · Free lesson

Start this lesson

Why it matters

This is the shape of most useful AI tools. Plain code does everything that can be checked with rules, cheaply and the same way every time. A model handles the one step that needs judgment or writing. Agents, evals and content pipelines in Phase 1 are bigger versions of what you build here. And you'll check a model's output with your own code constantly.

What you'll be able to do

  • Break a real task into load, check, report and generate steps
  • Write rule checks as small functions that return a list of problems
  • Save results as a JSON report other tools can read
  • Use one LLM call for the part rules can't do, and check what comes back

What it covers

  1. What you're building
  2. Step 1: load the data
  3. Step 3: a report other tools can read
  4. Worked example: steps 1 to 3 on subject lines
  5. What code should do, and what the model should do

What you're building

Before ads go live, someone checks the copy. Is each headline short enough for the placement? Does it avoid phrases that get ads rejected or sound spammy? You're going to automate that review for a CSV export of headlines. Then you'll ask a model to rewrite the worst one.

The program has four steps:

  1. Load the headlines from ads.csv into a list of dictionaries.
  2. Check each headline against two rules: at most 40 characters, and none of the banned phrases "click here", "guaranteed", "act now" or "!!!".
  3. Report: save every headline with its problems to report.json.
  4. Generate: ask the model for a better version of the headline with the most problems.

Each step is a small piece you already know. The skill you're practicing is putting them together, so each one does one job and hands clean data to the next.

For marketers: this is the same split you'd use with a junior copywriter and a style guide. The checklist catches the mechanical problems every time. The person is there for the rewrite. Here the checklist is code, and the rewrite comes from a model.

Step 1: load the data

csv.DictReader from the files lesson turns each row into a dictionary, keyed by the header row. Here it is on a different, smaller file:

import csv

with open("subjects.csv", "w") as f:
    f.write("""subject,audience
Last chance: spring sale ends tonight,customers
Your March report is ready,partners
""")

with open("subjects.csv") as f:
    rows = list(csv.DictReader(f))

print(len(rows))
print(rows[0])
print(rows[1]["subject"])
It runs right here in your browser, with nothing to install.

list(...) collects the rows while the file is still open. After the with block ends, the file is closed, but rows keeps the data.

Step 2: rules as functions that return problems

A good shape for a checker is a function that takes one item and returns a list of problems. An empty list means it passed. That's easy to test, easy to count, and easy to add new rules to later.

A comprehension builds that list in one line. Lowercasing the text first makes the check ignore capitals, so "FREE" and "free" are both caught:

SPAMMY = ["free", "winner", "$$$"]

def check_subject(subject):
    problems = [f"spammy word: {word}" for word in SPAMMY if word in subject.lower()]
    if len(subject) > 50:
        problems.append(f"too long ({len(subject)} characters)")
    return problems

print(check_subject("Your March report is ready"))
print(check_subject("FREE gift for our WINNER"))
print(len(check_subject("FREE gift for our WINNER")))
Try it now

Step through and watch problems fill up for the second subject, then become one entry in results.

Quick check
What does this return?
BANNED = ["guaranteed", "act now"]
headline = "Act Now: Results Guaranteed"
[p for p in BANNED if p in headline.lower()]
  1. ['Act Now', 'Guaranteed']
  2. []
  3. ['guaranteed', 'act now']
  4. ['act now']
Show the answer

['guaranteed', 'act now']

.lower() makes the headline "act now: results guaranteed", which contains both phrases. The comprehension goes through BANNED in order, so "guaranteed" comes first: ['guaranteed', 'act now'].

Quick check
check_subject returns [] for one subject line. What does that mean?
  1. The subject line was empty
  2. The rules weren't checked
  3. The function crashed
  4. It passed: no rule found a problem
Show the answer

It passed: no rule found a problem

A checker that returns a list of problems uses an empty list to mean "all clear". len(problems) then counts how bad an item is.

Step 3: a report other tools can read

Printing results is fine while you're working. But a file is what another script, a dashboard or a teammate can use. JSON is the natural format. It holds lists and dictionaries exactly as they are in Python, and every language and AI tool reads it.

import json

results = [
    {"subject": "Your March report is ready", "problems": [], "ok": True},
    {"subject": "FREE gift for our WINNER", "problems": ["spammy word: free", "spammy word: winner"], "ok": False},
]

with open("subject_report.json", "w") as f:
    json.dump(results, f, indent=2)

with open("subject_report.json") as f:
    print(f.read())

json.dump (no "s") writes straight to an open file, and indent=2 makes it readable.

Step 4: one model call, used carefully

Finding the worst headline is a job for a loop that remembers the best candidate so far:

results = [
    {"subject": "Report ready", "problems": []},
    {"subject": "FREE gift for our WINNER", "problems": ["free", "winner"]},
    {"subject": "Free shipping", "problems": ["free"]},
]

worst = results[0]
for r in results:
    if len(r["problems"]) > len(worst["problems"]):
        worst = r
print(worst["subject"])

Using > rather than >= means a tie keeps the earlier item.

Then the prompt. A useful prompt gives the model the same context you'd give a person: what the text is, what's wrong with it, the rules, and exactly what to return.

subject = "FREE gift for our WINNER"
problems = ["spammy word: free", "spammy word: winner"]

prompt = f"""Rewrite this email subject line.
Subject: {subject}
Problems found: {", ".join(problems)}
Rules: at most 50 characters, and avoid the words free and winner.
Reply with the new subject line only."""

print(prompt)

"Reply with the new subject line only" matters. Without it, models often add an introduction or several options, and your code would have to dig the answer out.

Finally, don't trust the output blindly. You already have a function that checks headlines, so run the suggestion through it. If it still has problems, you know before anyone else does. Checking model output with code is the seed of what Phase 1 calls evals.

Quick check
Why run the model's rewrite back through your own review() function?
  1. The API requires it
  2. It reduces the number of tokens billed
  3. The model can break the rules too, and your checks catch that automatically
  4. It makes the model's answer shorter
Show the answer

The model can break the rules too, and your checks catch that automatically

Models follow instructions most of the time, not every time. Reusing your rule-based checks on generated text is cheap insurance.

Worked example

Worked example: steps 1 to 3 on subject lines

The same load, check and report steps on a small file of email subject lines, ending with a count of how many passed for each audience.

import csv
import json

with open("subjects.csv", "w") as f:
    f.write("""subject,audience
Last chance: spring sale ends tonight,customers
FREE gift for every WINNER,customers
Your March report is ready,partners
You're a winner: free upgrade for partners who renew this week,partners
""")

SPAMMY = ["free", "winner", "$$$"]

def check_subject(subject):
    problems = [f"spammy word: {word}" for word in SPAMMY if word in subject.lower()]
    if len(subject) > 50:
        problems.append(f"too long ({len(subject)} characters)")
    return problems

with open("subjects.csv") as f:
    rows = list(csv.DictReader(f))

report = [{"subject": r["subject"], "audience": r["audience"], "problems": check_subject(r["subject"])} for r in rows]
with open("subject_report.json", "w") as f:
    json.dump(report, f, indent=2)

passed = {}
for r in report:
    passed[r["audience"]] = passed.get(r["audience"], 0) + (1 if not r["problems"] else 0)

for r in report:
    print(len(r["problems"]), r["subject"])
print(passed)
In the lesson you can change this and run it.

It prints the problem count for each subject line: 0, 2, 0 and 3. Then {'customers': 1, 'partners': 1}: one subject line passed for each audience.

The last partner line breaks all three rules. It mentions "winner" and "free", and it's 62 characters long. Each step hands clean data to the next: rows from the CSV, a report of dictionaries, then a count built from the report. Change a rule in check_subject, and the report and the counts update with it.

What code should do, and what the model should do

The most useful habit in this capstone is deciding who does what. Your headline checks, such as length, banned phrases and counting, run in plain Python. A model could do those too, but it would be slower, it would cost money on every call, and it would sometimes be wrong. Code gets them right every time, for free.

Save the model for what code can't do, such as rewriting a headline so it sounds better.

Then don't trust the rewrite. A model asked to fix a headline might produce one that's too long, or that uses a banned phrase in a new way. Run its answer back through the same rule functions you wrote in Step 2. If it fails, keep the original. That loop is a small version of how real AI products are built: the model suggests, and code checks.

Watch for two small bugs along the way. Forgetting .lower() means your banned-phrase check misses FREE and Free. And when you track the worst headline, compare with > and store the text you want to report, not the whole row. Test both with a made-up row that should fail. A check you've never seen fail may not be checking anything.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Start this lesson

Try a quiz question

Why have review() return a list of problems instead of just True or False?

  1. The list says what's wrong, can be counted, and an empty list still means it passed
  2. Lists are faster than booleans, which matters when you review thousands of headlines
  3. json.dump can't save booleans, so True or False couldn't go into the report
  4. Functions in Python must return lists whenever they check more than one rule at a time

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson