Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/projects/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say

<ProjectChooser
projects={mergeProjectMeta([
{
id: 'resume-tailor-agent',
title: 'Build a Resume & Cover-Letter Tailoring Agent',
summary:
'Score your match against a specific job description, draft a tailored cover letter, and list concrete resume edits — with a hard no-fabrication rule, using a free-tier LLM.',
},
{
id: '2027-dependency-freshness-checker',
title: 'Build a Dependency-Freshness Checker',
Expand Down
4 changes: 4 additions & 0 deletions docs/projects/resume-tailor-agent/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "Resume & Cover-Letter Tailoring Agent",
"position": 17
}
372 changes: 372 additions & 0 deletions docs/projects/resume-tailor-agent/index.md

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions examples/resume-tailor-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copy this file to .env (already gitignored) and fill in the key for
# whichever provider you choose -- you only need ONE of the keys below.
# Never commit a real key.

# Which provider to use: github (default), gemini, groq, mistral, cerebras,
# or openrouter. See tailor.py's PROVIDERS dict for what each one needs.
LLM_PROVIDER=github

# github (default) -- a GitHub personal access token with the "models: read"
# scope. Free, no separate signup: https://github.com/settings/tokens
GITHUB_TOKEN=

# gemini -- free-tier key from https://aistudio.google.com/
GOOGLE_API_KEY=

# groq -- free-tier key from https://console.groq.com/keys
GROQ_API_KEY=

# mistral -- free-tier key from https://console.mistral.ai/api-keys
MISTRAL_API_KEY=

# cerebras -- free-tier key from https://cloud.cerebras.ai/
CEREBRAS_API_KEY=

# openrouter -- free-tier key from https://openrouter.ai/keys
OPENROUTER_API_KEY=
4 changes: 4 additions & 0 deletions examples/resume-tailor-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
__pycache__
*.pyc
.env
1 change: 1 addition & 0 deletions examples/resume-tailor-agent/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
62 changes: 62 additions & 0 deletions examples/resume-tailor-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Resume & Cover-Letter Tailoring Agent Example

The local companion to the course's [Build a Resume & Cover-Letter Tailoring Agent](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/resume-tailor-agent) lesson -- a real, runnable CLI that reads your resume and a job description from text files and asks a free-tier LLM to score the match, draft a tailored cover letter, and list concrete resume edits. It only ever works from what's already on your resume -- no invented experience.

## What's here

`tailor.py` -- a single-file CLI with:

- `load_text(path)` -- reads a resume or job description from a `.txt` or `.md` file with `pathlib`.
- `SYSTEM_PROMPT` -- a tailoring-specific system prompt with a hard **no-fabrication rule**: never invent skills, titles, or dates; never upgrade a bullet point beyond what the resume plainly supports; report genuine gaps instead of papering over them.
- `tailor(resume, job, provider=...)` -- sends both documents to whichever free-tier provider you've configured and returns the structured result (match score, cover letter draft, resume edits).
- `truncate(text)` -- caps oversized documents before they're sent, so a long posting doesn't silently blow past a free-tier context window or token quota.

`sample_resume.txt` and `sample_job.txt` -- a bundled pair to try the tool on immediately, with enough deliberate overlap and gap to make the output interesting (the sample resume has zero machine-learning experience; the posting is for a junior ML engineer).

**You're free to use whichever free-tier provider you like** -- this isn't locked to any one of them. Six are wired up already: **GitHub Models** (the default -- no separate signup, uses a GitHub account you already have), Gemini, Groq, Mistral, Cerebras, and OpenRouter, all through the same `openai` client pointed at each provider's own OpenAI-compatible endpoint.

## Running it

1. **Get a free-tier API key** from your chosen provider -- see the table in the [lesson's Setup section](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/resume-tailor-agent#get-a-free-llm-api-key) for where to get one for each.
2. **Copy `.env.example` to `.env`** and fill in the key for your provider (and `LLM_PROVIDER` if you're not using the default):
```bash
cp .env.example .env
# then edit .env
```
`.env` is already gitignored -- never commit a real key.
3. **Run it with `uv`** -- no manual virtual environment setup needed:
```bash
uv run python tailor.py sample_resume.txt sample_job.txt
# or with your own documents:
uv run python tailor.py my_resume.txt my_job.txt
uv run python tailor.py my_resume.txt my_job.txt --provider groq
```

`uv` reads `pyproject.toml`/`uv.lock` and creates an isolated environment for this project automatically on first run.

### The no-fabrication audit

Before you'd ever send a generated cover letter anywhere, do this once: print the draft to a file, and next to each claim write the resume line it comes from. Any claim with no supporting line is a fabrication -- and a sign you should tighten the system prompt or switch models.

## Running it in GitHub Codespaces

Click into a [Codespace for the whole repo](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are preinstalled) -- the bundled sample files are already there, so you can run every command above immediately, no setup beyond copying `.env.example` to `.env`.

## Try it with zero setup: `notebook.ipynb`

[`notebook.ipynb`](./notebook.ipynb) in this folder is a runnable notebook version of this same tool, for Colab, Kaggle, or Binder:

<!-- TODO: update these badge links to point at main once this PR merges -->
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/resume-tailor-agent/notebook.ipynb)
[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/resume-tailor-agent/notebook.ipynb)
[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fresume-tailor-agent%2Fnotebook.ipynb)

A notebook environment has no local files, which is this tool's whole premise -- so rather than pretending that gap doesn't exist, the notebook reads the bundled `sample_resume.txt` from the course repo, lets you paste a real job description (or fetch one from a URL), and asks for your API key interactively with `getpass`. Every other part of the tool -- the `pathlib` file reading, the system prompt, the LLM call, the structured output -- runs unmodified. It's a fast way to see the whole thing work end to end before setting it up locally; once you want to run it against your real resume, come back to `uv run python tailor.py` above or a Codespace.

## A note on staying current

Model names and provider free-tier terms change fast -- the model IDs and endpoints in `tailor.py`'s `PROVIDERS` dict were verified against a live run while writing this example, but check each provider's own docs before relying on them, since they may have drifted by the time you read this.

## Built your own version?

See [`examples/student-projects/`](../student-projects/) for how to share it with the class via a pull request -- no git experience required, it walks through every step.
275 changes: 275 additions & 0 deletions examples/resume-tailor-agent/notebook.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Resume & Cover-Letter Tailoring Agent -- notebook demo\n",
"\n",
"This notebook is a runnable demo of the **Resume & Cover-Letter Tailoring Agent** project from the course: [`docs/projects/resume-tailor-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/resume-tailor-agent), companion to the fuller local CLI at [`examples/resume-tailor-agent/tailor.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/resume-tailor-agent/tailor.py).\n",
"\n",
"It reads a resume and a job description as text, hands them to a free-tier LLM with an honest-tailoring system prompt, and prints back a match score, a cover-letter draft, and a list of concrete resume edits."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## A note on running this in a notebook\n",
"\n",
"The real version of this tool (`examples/resume-tailor-agent/tailor.py`) reads **your own resume and job descriptions** from files on disk -- that's the whole point of the tool. Colab, Kaggle, and Binder don't have your files.\n",
"\n",
"So **this demo adapts the tool**: it reads the bundled sample resume (`sample_resume.txt`) straight from the course repo, and you paste a real job description below (or fetch one from a URL). That runs every piece of the tool (the file reading, the system prompt, the LLM call, the structured output) honestly -- it's just not pointed at your real resume. **Locally, or in a GitHub Codespace, you'd point it at your own files instead** -- see the [project walkthrough](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/resume-tailor-agent) for that path."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"!pip install -q openai"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Get the sample resume\n",
"\n",
"Fetch the bundled `sample_resume.txt` straight from the course repo -- a fictional data analyst with no machine-learning experience, so it makes an interesting (honest!) run against an ML posting."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import urllib.request\n",
"\n",
"url = \"https://raw.githubusercontent.com/abderrahim-lectures/python-data-analysis-course/main/examples/resume-tailor-agent/sample_resume.txt\"\n",
"resume = urllib.request.urlopen(url).read().decode(\"utf-8\")\n",
"print(f\"Loaded sample resume: {len(resume)} characters\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Get a job description\n",
"\n",
"Fetch the bundled `sample_job.txt` straight from the course repo -- a fictional\n",
"*Junior Machine Learning Engineer* posting, so paired with the sample data\n",
"analyst resume above it makes an interesting (honest!) run. To use a real\n",
"posting instead, replace the `url` below with a plain-text URL, or paste the\n",
"text of a job description into the `job` string."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import urllib.request\n",
"\n",
"url = \"https://raw.githubusercontent.com/abderrahim-lectures/python-data-analysis-course/main/examples/resume-tailor-agent/sample_job.txt\"\n",
"job = urllib.request.urlopen(url).read().decode(\"utf-8\")\n",
"print(f\"Loaded sample job description: {len(job)} characters\")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## The tailoring system prompt\n",
"\n",
"This is the exact `SYSTEM_PROMPT` from `tailor.py` -- it's what turns a general-purpose chat model into a strict, honest tailoring assistant. The no-fabrication rule is the whole point: never invent skills or titles, never upgrade a bullet point, report gaps instead of hiding them."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"SYSTEM_PROMPT = \"\"\"\\\n",
"You are a meticulous, honest resume-and-cover-letter tailoring assistant.\n",
"\n",
"You will be given a RESUME and a JOB DESCRIPTION. Your job is to help the\n",
"candidate apply for THIS job, using ONLY facts that already exist in their\n",
"resume. This is non-negotiable:\n",
"\n",
"- NEVER invent skills, technologies, tools, titles, employers, projects,\n",
" dates, numbers, or credentials that are not already on the resume.\n",
"- NEVER reword an existing bullet point into something that is not plainly\n",
" supported by it. Rephrase and re-order freely, but do not upgrade.\n",
"- If the resume is missing something the job clearly asks for, say so in\n",
" the resume edits list instead of pretending the candidate has it.\n",
"\n",
"Produce exactly three sections:\n",
"\n",
"1. MATCH SCORE: A number from 0-100 with a two-sentence rationale. Be\n",
" honest -- an 82 with a clear explanation beats a 95 that can't be backed\n",
" up by the resume.\n",
"\n",
"2. COVER LETTER DRAFT: A complete, ready-to-edit cover letter of 2-3 short\n",
" paragraphs, addressed to a hiring manager, that connects specific items\n",
" already on the resume to the specific requirements of THIS job. Every\n",
" claim it makes must trace back to the resume.\n",
"\n",
"3. RESUME EDITS: A numbered list of concrete, actionable changes to make\n",
" to the resume for this job -- reordering bullets, swapping which\n",
" projects get highlighted, removing irrelevant lines, adding keywords\n",
" that genuinely match existing experience. Each edit states what to\n",
" change and why. Where the resume genuinely lacks something the job\n",
" wants, state that plainly as a gap, never as a fake achievement.\n",
"\n",
"Be specific and concrete throughout. Do not pad. Do not flatter.\n",
"\"\"\""
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Get a free-tier API key\n",
"\n",
"This demo defaults to **GitHub Models** -- free, no separate signup, just a personal access token with the `models: read` scope from [github.com/settings/tokens](https://github.com/settings/tokens). Any of the other five providers wired up in `tailor.py` (Gemini, Groq, Mistral, Cerebras, OpenRouter) work too -- see that file's `PROVIDERS` dict for their base URLs and env var names, and adjust `LLM_PROVIDER` below.\n",
"\n",
"The key is entered with `getpass` so it never gets typed into a visible cell or saved into this notebook's output -- never hardcode a real API key here."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import os\n",
"from getpass import getpass\n",
"\n",
"LLM_PROVIDER = \"github\" # change to gemini / groq / mistral / cerebras / openrouter if you prefer\n",
"os.environ[\"GITHUB_TOKEN\"] = getpass(\"Enter your free-tier GitHub Models token (GITHUB_TOKEN): \")"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## The tailoring logic itself\n",
"\n",
"This mirrors `truncate`, `PROVIDERS`, and `tailor` from `tailor.py` directly -- the same truncation cap, the same free-tier providers (all exposed through the `openai` client, just pointed at each provider's own OpenAI-compatible endpoint), and the same call shape."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from openai import OpenAI\n",
"\n",
"MAX_TEXT_CHARS = 30_000\n",
"\n",
"\n",
"def truncate(text: str, max_chars: int = MAX_TEXT_CHARS) -> str:\n",
" \"\"\"Cuts an oversized document down to a size that fits a free-tier context window.\"\"\"\n",
" if len(text) <= max_chars:\n",
" return text\n",
" return text[:max_chars] + f\"\\n\\n... [truncated -- {len(text) - max_chars} more characters not shown] ...\"\n",
"\n",
"\n",
"def _build_github_client() -> OpenAI:\n",
" return OpenAI(api_key=os.environ[\"GITHUB_TOKEN\"], base_url=\"https://models.github.ai/inference\")\n",
"\n",
"\n",
"def _build_gemini_client() -> OpenAI:\n",
" return OpenAI(\n",
" api_key=os.environ[\"GOOGLE_API_KEY\"],\n",
" base_url=\"https://generativelanguage.googleapis.com/v1beta/openai/\",\n",
" )\n",
"\n",
"\n",
"def _build_groq_client() -> OpenAI:\n",
" return OpenAI(api_key=os.environ[\"GROQ_API_KEY\"], base_url=\"https://api.groq.com/openai/v1\")\n",
"\n",
"\n",
"def _build_mistral_client() -> OpenAI:\n",
" return OpenAI(api_key=os.environ[\"MISTRAL_API_KEY\"], base_url=\"https://api.mistral.ai/v1\")\n",
"\n",
"\n",
"def _build_cerebras_client() -> OpenAI:\n",
" return OpenAI(api_key=os.environ[\"CEREBRAS_API_KEY\"], base_url=\"https://api.cerebras.ai/v1\")\n",
"\n",
"\n",
"def _build_openrouter_client() -> OpenAI:\n",
" return OpenAI(api_key=os.environ[\"OPENROUTER_API_KEY\"], base_url=\"https://openrouter.ai/api/v1\")\n",
"\n",
"\n",
"PROVIDERS = {\n",
" \"github\": (_build_github_client, \"gpt-4o-mini\"),\n",
" \"gemini\": (_build_gemini_client, \"gemini-3.5-flash\"),\n",
" \"groq\": (_build_groq_client, \"llama-3.3-70b-versatile\"),\n",
" \"mistral\": (_build_mistral_client, \"mistral-small-latest\"),\n",
" \"cerebras\": (_build_cerebras_client, \"llama-3.3-70b\"),\n",
" \"openrouter\": (_build_openrouter_client, \"meta-llama/llama-3.3-70b-instruct:free\"),\n",
"}\n",
"\n",
"\n",
"def tailor(resume: str, job: str, provider: str = LLM_PROVIDER) -> str:\n",
" \"\"\"Sends a resume + job description to a free-tier LLM and returns the tailored result.\"\"\"\n",
" if provider not in PROVIDERS:\n",
" raise ValueError(f\"Unknown provider '{provider}'. Choose one of: {', '.join(PROVIDERS)}\")\n",
" build_client, model = PROVIDERS[provider]\n",
" client = build_client()\n",
"\n",
" response = client.chat.completions.create(\n",
" model=model,\n",
" messages=[\n",
" {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
" {\n",
" \"role\": \"user\",\n",
" \"content\": f\"RESUME:\\n```\\n{truncate(resume)}\\n```\\n\\nJOB DESCRIPTION:\\n```\\n{truncate(job)}\\n```\",\n",
" },\n",
" ],\n",
" )\n",
" return response.choices[0].message.content"
],
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Run the tailoring\n",
"\n",
"Using the bundled sample resume and the job description from above -- every section the prompt asks for (match score, cover letter draft, resume edits) should print. Then do the **no-fabrication audit** from the lesson: read the draft claim by claim against the resume, and check nothing was invented."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"print(f\"Tailoring a {len(resume)}-char resume against a {len(job)}-char job description...\\n\")\n",
"result = tailor(resume, job)\n",
"print(result)"
],
"execution_count": null,
"outputs": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading