Language · one program

Python for AI

Most model SDKs and agent frameworks are Python-first. You do not need the whole language. You need text, dicts, functions, files, errors, type hints, Pydantic, and async — used on the same support-ticket program until it is real.

BeginnerPython 3.10+

Why AI code looks like this

AnalogyPython for AI is a pocket knife, not the whole toolbox. Five tickets can teach the whole pocket knife: read them, label them, check the label, save it.
Data flow
File of tickets→Your function→Model or stand-in→Check the shape→Save JSON

OpenAI, Anthropic, Google, Hugging Face, LangChain, and LlamaIndex all ship Python first. Early on, a stand-in function can pretend to be the model so you learn the program with no API key. Swap the stand-in for a real client at the end. Use a virtual environment and pin packages. AI libraries move fast.

Starter

tickets = [
    {"id": "T1", "text": "Charged twice for order A-1001"},
    {"id": "T2", "text": "Package has not moved in 6 days"},
]
print(len(tickets), "tickets ready")

Example

A nightly job that labels 500 tickets has the same shape as a chatbot: load work, call a model, validate, persist.

OpenAI, Anthropic, Google, Hugging Face, LangChain, and LlamaIndex all ship Python first. The same five tickets can teach the whole slice: read them from a file, ask a model to label each one, check the label, save the result.

tickets = [
    {"id": "T1", "text": "Charged twice for order A-1001"},
    {"id": "T2", "text": "Package has not moved in 6 days"},
    {"id": "T3", "text": "How do I change my email?"},
]

Early on, a stand-in function can pretend to be the model so you learn the program with no API key. Swap in a real client at the end.

Strings, lists, and dictionaries

AnalogyA dictionary is a labeled drawer. A list is a row of drawers. Model JSON becomes drawers you open with strings, not with positions.
Data flow
JSON text→dict→list of dicts→filter→the rows you keep

You will write this comprehension again to drop low-score search hits. Index with strings (ticket['id']), not with hope. If a key might be missing, use .get('priority', 'low') so one bad row does not crash the batch.

Starter

tickets = [{"id": "T1", "text": "refund"}, {"id": "T2", "text": "hello"}]
hot = [t for t in tickets if "refund" in t["text"]]
print(hot[0]["id"])

Example

An on-call bot keeps only incidents where severity is “page.” Same filter, different keys.

A ticket is a dictionary. A batch is a list of dictionaries. Model JSON comes back as text, then becomes a dictionary you index with strings.

ticket = tickets[0]
print(ticket["id"], ticket["text"])

labels = []
for ticket in tickets:
    labels.append({"id": ticket["id"], "topic": "billing"})

# same idea, written as a comprehension
ids = [t["id"] for t in tickets if "order" in t["text"].lower()]

Use case: log lines into rows

An on-call bot reads a list of incident dicts (service, severity, message) and keeps only severity == "page". That filter is the same comprehension you will later use to drop low-score RAG chunks.

Functions are how tools are born

AnalogyA tool is a function with a name, arguments, and a return value. Frameworks only wrap that function. Write the plain function first.
Data flow
Arguments→Function→Return value→Framework wraps it as a tool

Default arguments show up everywhere: `def search(query: str, k: int = 4)`. The default is what you get when the model forgets to pass k. Keep the docstring honest. Later, that docstring is what the model reads to decide whether to call the tool.

Starter

def classify(text: str) -> dict:
    if "refund" in text.lower():
        return {"topic": "billing", "priority": "high"}
    return {"topic": "other", "priority": "low"}

print(classify("Please refund order A17"))

Example

lookup_order(order_id) is this same shape. The framework adds a schema. Your code still just returns a string.

A tool is a function with a clear name, arguments, and a return value. Write the plain function first. Frameworks only wrap it.

def classify(text: str) -> dict:
    lowered = text.lower()
    if "charged" in lowered or "refund" in lowered:
        return {"topic": "billing", "priority": "high"}
    if "package" in lowered or "shipping" in lowered:
        return {"topic": "shipping", "priority": "medium"}
    return {"topic": "other", "priority": "low"}

print(classify("Charged twice for order A-1001"))

Default arguments show up constantly: def search(query: str, k: int = 4). Keyword arguments keep call sites readable when a client has ten options.

Files and JSON

AnalogyJSON on disk is the contract between tonight’s script and tomorrow’s script. If you cannot round-trip it, you do not have a pipeline.
Data flow
Path→read text→json.loads→your program→json.dumps→write text

json.loads reads a string. json.load reads a file object. Always pass encoding utf-8. Model output often arrives wrapped in a markdown fence. Strip the fence or, better, ask for structured output so you never parse by hand.

Starter

import json
raw = '{"id": "T1", "text": "refund"}'
ticket = json.loads(raw)
print(ticket["id"])
print(json.dumps(ticket))

Example

Goldens for an eval, a config file, and a batch of labels are three files with the same two functions: loads and dumps.

Goldens, configs, and model replies live in JSON. Always set the encoding, and use a with block so the file closes.

import json
from pathlib import Path

raw = Path("tickets.json").read_text(encoding="utf-8")
tickets = json.loads(raw)

result = {"id": "T1", "topic": "billing", "priority": "high"}
Path("out.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
Gotcha. json.loads reads a string. json.load reads a file object. Model output often has a markdown fence around the JSON. Strip ```json before you parse, or ask for structured output so you never parse by hand.

Errors, timeouts, retries

AnalogyA retry is knocking again when the network blinked. It is not knocking again when you typed the wrong address. Those are different errors.
Data flow
Call→Timeout or 429→Wait→Try again→Give up with a clear error

Backoff means 0.5s, then 1s, then 2s, so you do not stampede a rate limit. Catch a narrow error. A 400 from a bad prompt will fail forever and burn quota if you retry it. After the last try, raise a new error that names how many attempts you made.

Starter

def call():
    raise TimeoutError("slow model")

try:
    call()
except TimeoutError as exc:
    print("retry this one:", exc)
# do not retry ValueError from a broken prompt; it fails the same way every time

Example

A nightly ticket job should retry rate limits and timeouts. It should park a bad prompt in errors.json and move on.

Model calls fail: network, rate limit, bad JSON, empty content. Catch the narrow error, wait, try again, then give up with a message a human can read.

import time

def call_with_retry(fn, attempts=3):
    last_error = None
    for n in range(attempts):
        try:
            return fn()
        except (TimeoutError, ValueError) as exc:
            last_error = exc
            time.sleep(0.5 * (2 ** n))  # 0.5s, 1s, 2s
    raise RuntimeError(f"gave up after {attempts} tries") from last_error

Use case: a nightly ticket job

Five hundred tickets, a few 429s from the provider. Retry with backoff on rate limits. Do not retry a 400 caused by a broken prompt — that will fail the same way every time and burn the quota.

Type hints and Pydantic

AnalogyA type hint is a label on the drawer. Pydantic is the lock that checks the label when a model, not you, fills the drawer.
Data flow
Model text→Pydantic model→Valid object or error→dict for your database

Hints alone do not check anything at runtime. Pydantic does. A missing field should fail here, not three services later. `model_dump()` is the plain dict you save. Field descriptions later become instructions to the model when you pass the class to a structured-output API.

Starter

from pydantic import BaseModel

class TicketLabel(BaseModel):
    topic: str
    priority: str

label = TicketLabel(topic="billing", priority="high")
print(label.model_dump())

Example

A clinic intake form with patient_name, symptom, and urgent: bool is the same lock. A wrong type never reaches the database.

A type hint documents the shape. Pydantic checks it at runtime, which is what you want when a model fills the fields.

from pydantic import BaseModel, Field

class TicketLabel(BaseModel):
    topic: str = Field(description="billing, shipping, or other")
    priority: str = Field(description="low, medium, or high")

label = TicketLabel(topic="billing", priority="high")
print(label.model_dump())

If the model returns priority: 1 and you declared str, Pydantic can coerce or reject it depending on the version and config. Rejection is what you want for labels. Later LangChain pages pass this same class to with_structured_output.

Use case: an intake form

A clinic bot must return patient_name, symptom, and urgent: bool. Pydantic is the gate before that object touches a database. A missing field fails here, not three services later.

Async when you wait on many calls

AnalogyAsync is waiting for several phone calls at once. A model call is mostly waiting. A loop waits in a line. gather waits side by side.
Data flow
20 tickets→20 waiting calls→gather→all results

Twenty calls of one second each take about twenty seconds in a line and about one second side by side. Cap concurrency with a semaphore of 5 so you do not hit the rate limit. Do not mix time.sleep into async code. It blocks the whole loop. Use asyncio.sleep.

Starter

import asyncio

async def label_one(ticket_id: str) -> str:
    await asyncio.sleep(0.01)  # stand-in for the network
    return ticket_id

async def main():
    done = await asyncio.gather(label_one("T1"), label_one("T2"))
    print(done)

asyncio.run(main())

Example

Labeling a queue of support tickets, embedding a folder of files, and checking ten goldens are the three places this pays off first.

A model call is mostly waiting. async lets you wait on several at once instead of in a line.

import asyncio

async def label_one(ticket):
    await asyncio.sleep(0.1)  # stand-in for the network
    return {"id": ticket["id"], **classify(ticket["text"])}

async def main():
    tasks = [label_one(t) for t in tickets]
    done = await asyncio.gather(*tasks)
    print(done)

asyncio.run(main())

Rule of thumb: if you label 20 tickets and each call takes 1 second, a loop takes about 20 seconds. gather takes about 1 second plus a little overhead. Cap concurrency (a semaphore of 5) so you do not stampede the rate limit.

Put it together: ticket triage

AnalogyThe finished program is a sandwich: load, call, check, save. Every framework you meet later is seasoning on that sandwich.
Data flow
tickets.json→stand-in or model→Pydantic→labels.json

Keep the stand-in until the shape is right. Then replace only `stand_in` with a real client. Failures should land in an errors file, not disappear into a default label, once you care about quality. Pin the packages in requirements.txt.

Starter

import json
from pydantic import BaseModel, ValidationError

class TicketLabel(BaseModel):
    topic: str
    priority: str

def stand_in(text: str) -> str:
    topic = "billing" if "refund" in text.lower() else "other"
    return json.dumps({"topic": topic, "priority": "low"})

try:
    print(TicketLabel.model_validate_json(stand_in("refund me")))
except ValidationError as exc:
    print("bad model output", exc.error_count())

Example

That sandwich is an intake bot, a nightly classifier, and the inside of a RAG eval. The files change. The order does not.

import json
from pathlib import Path
from pydantic import BaseModel, Field, ValidationError

class TicketLabel(BaseModel):
    topic: str = Field(pattern="^(billing|shipping|other)$")
    priority: str = Field(pattern="^(low|medium|high)$")

def stand_in_model(text: str) -> str:
    guess = classify(text)
    return json.dumps(guess)

def triage(path: str) -> list[dict]:
    tickets = json.loads(Path(path).read_text(encoding="utf-8"))
    saved = []
    for ticket in tickets:
        try:
            label = TicketLabel.model_validate_json(stand_in_model(ticket["text"]))
        except ValidationError:
            label = TicketLabel(topic="other", priority="low")
        saved.append({"id": ticket["id"], **label.model_dump()})
    Path("labels.json").write_text(json.dumps(saved, indent=2), encoding="utf-8")
    return saved

That is the whole shape of an AI program: load work, call a model, validate, persist. Frameworks add tracing and tools around this loop. They do not replace it.

Environments. Use a virtual environment (python -m venv .venv) and pin packages in requirements.txt. AI libraries move fast; “it worked on my laptop” is usually an unpinned upgrade.

Practice

  • Add a fourth ticket and a rule so “password reset” becomes topic account. Update the regex.
  • Save failed validations to errors.json instead of silently relabeling them.
  • Rewrite triage with asyncio.gather and a semaphore of 3.
  • Explain model_dump() versus printing the Pydantic object.