diff --git a/docs/projects/index.mdx b/docs/projects/index.mdx index e22e291..fbd924d 100644 --- a/docs/projects/index.mdx +++ b/docs/projects/index.mdx @@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say + + + +Every job description is written to be read quickly, and the resumes that get shortlisted are the ones that look like they were written for *that specific posting* — not a generic document mailed to a hundred employers. This project builds a CLI tool that does the first draft of that tailoring for you: it reads your resume and a job description as plain text files, asks a free-tier language model to (1) score how well you match, (2) write a cover letter draft shaped around that specific posting, and (3) list concrete resume edits — with one hard rule baked into the system prompt: **only ever use facts that are already on your resume. Never invent skills, titles, or dates.** + +This assumes Python 101 and nothing from Data Analysis. It's optional and ungraded; see [Real-World Projects](/docs/projects) for the full, growing list. + +## 🎯 What you'll do + +1. Install `uv`, get a free-tier LLM API key, and set up a small project — all in one place, before any building starts. +2. Load your resume and a job description from real text files with `pathlib`, so the tool works on documents you already have. +3. Design a system prompt that turns a general-purpose LLM into a strict tailoring assistant with an explicit "no fabrication" rule. +4. Send both documents to the model and print back a structured result: a match score, a cover-letter draft, and a list of concrete resume edits. +5. Run the whole tool against a sample resume and a real job description, then judge whether the output is honest and useful. + +## Where to run this + +**Locally with `uv`** is the primary, recommended path here — the tool's whole job is reading *your own* resume and job descriptions from files on your machine, so it works best where those documents actually live. + +**GitHub Codespaces** works fine too: open [the whole course repo in a free Codespace](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are already installed), and run the same `uv` commands from a terminal in your browser tab. There's even a sample resume and a sample job description bundled in the example folder to try it on immediately. + +**Google Colab, Kaggle Notebooks, and Binder** work for trying the idea out — nothing here needs a GPU. The notebook version of this project asks for your API key interactively with `getpass`, uses the bundled sample resume, and lets you paste a real job description (or fetch one from a URL). Use it to see the tool work end to end with zero setup; switch to local `uv` once you want it pointed at your real resume: + +[![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) + +**opencode** *(optional)* — a free, open-source AI coding agent that runs in your terminal. If you'd rather have an agent write and run this project for you than type the code yourself, install it with `curl -fsSL https://opencode.ai/install | bash` (or `npm install -g opencode-ai`) and point it at this repo with the same API key from Setup below. It's optional — this project's whole point is building it yourself, so treat it as a bonus, not a shortcut. + +## Setup + +Everything you need before you write a line of the tailor itself: a real Python, a free API key, and a small project to hold both. + +### Install `uv` + +`uv` is a single tool that replaces the usual "install Python, then install pip, then install a virtual environment tool, then install packages" chain — it can install and manage Python versions itself, alongside your project's dependencies. + +**macOS / Linux** (terminal): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows** (PowerShell): + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Close and reopen your terminal, then confirm it installed: + +```bash +uv --version +``` + +### Set up the project + +```bash +uv init resume-tailor-agent +cd resume-tailor-agent +uv add openai python-dotenv +``` + +`openai`'s client library works here for every provider in the table below, not just OpenAI itself — GitHub Models, Gemini, Groq, Mistral, Cerebras, and OpenRouter all expose an OpenAI-compatible chat endpoint, so one client, pointed at a different `base_url`, is all this project needs. `python-dotenv` lets you keep your API key in a local `.env` file instead of `export`-ing it every session. + +### Get a free LLM API key + +**Pick whichever provider you like** — none of them require a credit card at the time of writing, and this course doesn't favor one over another. The fuller example in the course repo ([`examples/resume-tailor-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/resume-tailor-agent)) supports all six out of the box, selected with one setting. + +| Provider | Where to get a key | Why you might pick it | +|---|---|---| +| **GitHub Models** *(suggested default)* | [github.com/settings/tokens](https://github.com/settings/tokens) — a personal access token with the `models: read` scope | No separate signup — you already have a GitHub account. More generous free-tier limits than Gemini's. | +| Gemini | [Google AI Studio](https://aistudio.google.com/) | The most commonly referenced option; also exposes an OpenAI-compatible endpoint, used below. | +| Groq | [console.groq.com/keys](https://console.groq.com/keys) | Fast inference, generous free tier, no card. | +| Mistral | [console.mistral.ai/api-keys](https://console.mistral.ai/api-keys) | One of the more generous permanent free quotas. | +| Cerebras | [cloud.cerebras.ai](https://cloud.cerebras.ai/) | High daily token volume, no card. | +| OpenRouter | [openrouter.ai/keys](https://openrouter.ai/keys) | One API, many free models — good for comparing providers. | + +Whichever you pick, the process is the same: + +1. Sign in and generate an API key on that provider's site. +2. **Never paste this key directly into code or commit it to a repository.** Create a `.env` file in your project folder instead (never commit this): + +```bash +# .env +LLM_PROVIDER=github +GITHUB_TOKEN=your-key-here +``` + +An API key is a secret, exactly like a password — anyone with it can use your account's quota. Treating it as an environment variable rather than a hardcoded string is the standard practice for exactly this reason. + +:::tip[A .env file is often more convenient than export] +Instead of `export`-ing a key in every new terminal session, `python-dotenv` reads a `.env` file in your project folder into `os.environ` automatically, the first time your script runs — see `load_dotenv()` in Step 3 below. +::: + +**✅ Checklist** + + +`uv --version` prints a version number. +`resume-tailor-agent/` exists with a `pyproject.toml`, and `openai` and `python-dotenv` are installed. +You have a real API key from one provider, saved in a `.env` file in your project folder — not pasted into any script. + + +## Step 1: Read the resume and job description from files + +`pathlib.Path` is Python's modern way of handling file paths, and `.read_text()` turns a whole file into one string. The tool needs two inputs — your resume and the job description — so the first decision is *where they come from*. Two files passed on the command line is the simplest, most honest design, and it means the tool works on documents you already have on disk: + +```python +# tailor.py +import argparse +from pathlib import Path + + +def load_text(path: str | Path) -> str: + """Reads a plain-text file and returns its contents as a single string.""" + return Path(path).read_text(encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Tailor a resume and draft a cover letter for a specific job description." + ) + parser.add_argument("resume", help="Path to your resume as a .txt or .md file.") + parser.add_argument("job", help="Path to the job description as a .txt or .md file.") + parser.add_argument("--provider", help="Override LLM_PROVIDER for this run, e.g. 'groq'.") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + resume = load_text(args.resume) + job = load_text(args.job) + print(f"Loaded resume: {len(resume)} characters") + print(f"Loaded job description: {len(job)} characters") +``` + +`encoding="utf-8"` matters more than it might seem — without it, Python falls back to a platform-dependent default encoding, and the same script can silently misread accented characters on Windows versus macOS/Linux. Being explicit about the encoding is the reliable choice for text that may contain them (names, languages, and job titles often do). + +:::tip[Resumes and job descriptions are just text] +A `.docx` or PDF is a real resume, but for this project the honest input is plain text: export your resume to `.txt` or `.md` (any text editor can do this), and copy a job description's text into a file. The model reads characters, not formatting — bold, bullets, and page layout are irrelevant to it anyway. +::: + +**✅ Checklist** + + +You can run `uv run python tailor.py path/to/resume.txt path/to/job.txt` and it prints the character counts of both files. +You have a copy of your real resume as a `.txt` or `.md` file, and at least one real job description saved the same way. +Running the script with a typo'd file path raises a clear `FileNotFoundError`, not a confusing error from somewhere deeper. + + +**🤔 Socratic Question(s)** + +- Why read the resume and job description from files rather than hardcoding them as strings in the script? What does that choice buy you beyond "the script is shorter"? +- The script reads two files but does nothing with their contents yet. What's the value of building and testing this input-handling step before the LLM part exists, instead of writing the whole thing at once? + +## Step 2: Design the tailoring system prompt + +The difference between a helpful tailoring assistant and a liability is almost entirely in the system prompt. A model told only "tailor my resume" will happily invent plausible-sounding skills, rename your job titles, and stretch dates — producing a document that sounds great and gets you into trouble in an interview. The prompt below makes honesty the whole point of the tool: + +```python +SYSTEM_PROMPT = """\ +You are a meticulous, honest resume-and-cover-letter tailoring assistant. + +You will be given a RESUME and a JOB DESCRIPTION. Your job is to help the +candidate apply for THIS job, using ONLY facts that already exist in their +resume. This is non-negotiable: + +- NEVER invent skills, technologies, tools, titles, employers, projects, + dates, numbers, or credentials that are not already on the resume. +- NEVER reword an existing bullet point into something that is not plainly + supported by it. Rephrase and re-order freely, but do not upgrade. +- If the resume is missing something the job clearly asks for, say so in + the resume edits list instead of pretending the candidate has it. + +Produce exactly three sections: + +1. MATCH SCORE: A number from 0-100 with a two-sentence rationale. Be + honest -- an 82 with a clear explanation beats a 95 that can't be backed + up by the resume. + +2. COVER LETTER DRAFT: A complete, ready-to-edit cover letter of 2-3 short + paragraphs, addressed to a hiring manager, that connects specific items + already on the resume to the specific requirements of THIS job. Every + claim it makes must trace back to the resume. + +3. RESUME EDITS: A numbered list of concrete, actionable changes to make + to the resume for this job -- reordering bullets, swapping which + projects get highlighted, removing irrelevant lines, adding keywords + that genuinely match existing experience. Each edit states what to + change and why. Where the resume genuinely lacks something the job + wants, state that plainly as a gap, never as a fake achievement. + +Be specific and concrete throughout. Do not pad. Do not flatter. +""" +``` + +Four deliberate design choices worth noticing: + +- **The no-fabrication rule is stated first and absolutely** ("This is non-negotiable"), because it's the one failure mode that actively hurts the user — a cover letter full of invented experience isn't a bad first draft, it's a liability. +- **"Do not upgrade"** closes a subtler hole: a model that refuses to *invent* might still happily turn "used pandas" into "built production data pipelines with pandas". The prompt forbids rephrasing that isn't plainly supported by the original bullet. +- **A required output structure** (score → letter → edits) makes the result actionable and keeps the model from drifting into generic career advice. +- **The "say it's a gap" instruction** channels the model's tendency to please into something useful: instead of quietly papering over a missing requirement, it tells you exactly what to go learn or honestly address. + +:::tip[The prompt is a spec you will iterate on] +Run this against your real resume and a real job description, then read the output critically. If the score feels inflated, tighten the scoring instructions. If the letter drifts toward generic, re-read the "connect specific items ... to the specific requirements" line. Treat the prompt like code with bugs in it, not a finished artifact. +::: + +**✅ Checklist** + + +You can explain, in your own words, the difference between "never invent" and "do not upgrade", and why a tailoring tool needs both. +The prompt specifies a concrete output structure (score, cover letter, edits), not just "help me apply". + + +**🤔 Socratic Question(s)** + +- A model given only "tailor this resume to this job" will often invent experience. What specifically in the prompt above is meant to stop that, and where do you think a model might still slip through — and why does that mean the prompt alone isn't a complete safety guarantee? +- The prompt asks the model to report missing requirements as gaps instead of hiding them. How is that more useful to the candidate than a cover letter that quietly avoids mentioning the missing requirement? + +## Step 3: Call the LLM and print the tailored result + +Wire the file-reading from Step 1 and the system prompt from Step 2 together into one working tool: + +```python +# tailor.py (continued -- add these imports and functions) +import os + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv() # reads .env into the environment, if present + +MAX_TEXT_CHARS = 30_000 # see the "overlong documents" pitfall below + + +def truncate(text: str, max_chars: int = MAX_TEXT_CHARS) -> str: + """Cuts an oversized document down to a size that fits a free-tier context window.""" + if len(text) <= max_chars: + return text + return text[:max_chars] + f"\n\n... [truncated -- {len(text) - max_chars} more characters not shown] ..." + + +def tailor(resume: str, job: str, provider: str | None = None) -> str: + """Sends a resume + job description to a free-tier LLM and returns the tailored result.""" + client = OpenAI( + api_key=os.environ["GITHUB_TOKEN"], + base_url="https://models.github.ai/inference", + ) + response = client.chat.completions.create( + model="gpt-4o-mini", # confirm this still has a free tier before running + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"RESUME:\n```\n{truncate(resume)}\n```\n\nJOB DESCRIPTION:\n```\n{truncate(job)}\n```", + }, + ], + ) + return response.choices[0].message.content + + +if __name__ == "__main__": + args = parse_args() + resume = load_text(args.resume) + job = load_text(args.job) + print(f"Loaded resume: {len(resume)} characters") + print(f"Loaded job description: {len(job)} characters\n") + print("Generating match score, cover letter draft, and resume edits...\n") + print(tailor(resume, job)) +``` + +`truncate` matters more here than it might first appear — see the pitfalls section below for why a very long resume or job description isn't just slow, it can silently fail or get a shallow result. Wrapping each document in a fenced code block in the user message, rather than pasting it in raw, is a small but real signal to the model about where one document ends and the other begins. + +Run it against the bundled sample files (or your real ones): + +```bash +uv run python tailor.py sample_resume.txt sample_job.txt +``` + +:::tip[Using a different provider?] +Swap the `OpenAI(...)` block for a different `base_url` and key — e.g. `base_url="https://api.groq.com/openai/v1"` with `api_key=os.environ["GROQ_API_KEY"]` for Groq, or `base_url="https://generativelanguage.googleapis.com/v1beta/openai/"` with `api_key=os.environ["GOOGLE_API_KEY"]` for Gemini's OpenAI-compatible endpoint. Everything else in this file stays the same. See [`examples/resume-tailor-agent/tailor.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/resume-tailor-agent/tailor.py) in the course repo for all six wired up side by side, selectable with one environment variable. +::: + +**✅ Checklist** + + +`uv run python tailor.py sample_resume.txt sample_job.txt` prints all three sections: a match score with rationale, a cover-letter draft, and a numbered list of resume edits. +Every claim in the cover letter draft traces back to something actually in the resume — you can verify it by reading the two side by side. +The resume edits are concrete ("reorder these two bullets", "drop this line"), not vague ("make it better"). + + +**🤔 Socratic Question(s)** + +- The user message fenced the resume and the job description as two separate code blocks. What would likely go wrong if they were sent as one blob of text with no clear boundary between them? +- If you ran this tool twice on the same resume and job description, would you expect identical output? What does that tell you about treating the match score as a definitive number versus a rough, conversation-starting estimate? + +## Step 4: Use it for real and judge the output + +The tool is only as good as your willingness to read its output critically. Two realistic ways to use it, both worth trying: + +**1. A full run on your real resume and a real job description** — the everyday use case. Save both as text files, then: + +```bash +uv run python tailor.py my_resume.txt my_job.txt +``` + +**2. The "no-fabrication" audit** — a deliberate quality check that this tool is uniquely well-suited for. Make a copy of the cover letter draft and, next to each claim, write the resume line it comes from. Any claim without a supporting line is a fabrication — and should not have been there. This is the same discipline the system prompt tries to bake in, and it's worth doing by hand at least once so you know how well your chosen model follows the rule: + +```bash +uv run python tailor.py my_resume.txt my_job.txt > draft.txt +# then annotate draft.txt claim-by-claim against your resume +``` + +**✅ Checklist** + + +You've run the tool on your real resume + a real job description, not just the samples. +You did the no-fabrication audit on the resulting cover letter and found every claim traced back to the resume — or found the fabricated ones and noted what the model invented. +You can name at least one resume edit the tool suggested that you actually agree with, and one you'd reject — the tool's output is a starting point, not gospel. + + +**🤔 Socratic Question(s)** + +- After the no-fabrication audit, if you found the model invented a skill you don't have, would you consider that a failure of the system prompt, of the model, or of the whole idea of automating this? How would you change the prompt to reduce it? +- The tool's resume edits sometimes suggest removing content to make room. How is a tailored resume different from a "best" resume, and why would one tool deliberately do that tradeoff for you? + +## ⚠️ Common pitfalls + +- **Fabricated experience slipping through.** The no-fabrication rule reduces inventing dramatically but doesn't eliminate it — language models are trained to be fluent, and "do not upgrade" can lose against a particularly eager model on a particularly tempting bullet point. This is exactly why Step 4's audit exists: the tool drafts, *you* verify. Never submit a generated cover letter without reading it against your resume line by line. +- **Overlong documents blowing past the context window or free-tier token quota.** A resume is usually short, but a verbose job description (some postings are paragraphs of boilerplate plus a long "requirements" list) can exceed what the model can attend to, or simply exceed your free tier's per-request token limit. `truncate` in Step 3 caps this, but truncation means a partial view — for genuinely long postings, paste the "requirements" section rather than the whole page. +- **Job descriptions that are scraped or PDF-converted garbage.** Copying text from a web page can drag in navigation menus, cookie banners, and formatting artifacts that waste tokens and confuse the model. Clean the text file before running the tool, and skim it once — if it has obvious junk, the output will be worse. +- **Treating the match score as objective truth.** The score is one model's opinion on one read, and it can be inflated, deflated, or swayed by how the posting is worded. Use it as a rough triage signal (which of several jobs to prioritize) rather than a verdict you optimize against by editing your resume's honesty. +- **Over-trusting the generated letter as final.** The draft is a strong starting point, not a finished letter in your voice. Run it, edit it, and make it sound like you — an AI-flavored generic letter is a recognizable (and counterproductive) first impression. + +## What you just built + +A real, working resume-tailoring CLI: it reads your resume and a job description from plain-text files, hands both to a free-tier LLM guided by a system prompt engineered specifically for honest tailoring — score, cover letter draft, and concrete edits — with a hard no-fabrication rule baked in. Nothing here is a toy simulation: point it at your actual resume and a real posting, and it produces a genuinely useful first draft of the work you'd otherwise do by hand, with the honesty boundary making it safe to use rather than a trap. + +:::tip[Run a fuller version without any local setup] +[`examples/resume-tailor-agent/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/resume-tailor-agent) in the course repo is a fuller version of the code above, with all six providers from the table wired up side by side (selected with one `LLM_PROVIDER` setting), a bundled sample resume and job description, and the `--provider` option from Step 4 already included. Clone it, or open the whole repo in a [GitHub Codespace](https://codespaces.new/abderrahim-lectures/python-data-analysis-course), and run it from there. +::: + +## Where to go from here + +- Add a `--output` flag that writes the cover letter draft to its own file, so you can open it in your editor and start editing immediately instead of copying from the terminal. +- Accept a job description **URL** as an alternative to a file, using `urllib.request` (or `requests`) to fetch the page and strip obvious HTML — handy when you find a posting in a browser and want to run it without saving a file first. +- Add a second pass that takes the first cover letter draft *and* your critique of it, and rewrites — an iterative refinement loop that models how you'd actually edit a draft by hand. +- Batch a whole folder of saved job descriptions: loop over them, generate a tailored output per posting, and sort them by match score to triage which applications deserve the most effort. That turns the single-posting tool into a genuine job-search workflow. + +## Share your project with the class + +Built something you're proud of? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) is a gallery of projects other students have submitted — and its README has a full, beginner-friendly walkthrough for adding yours via a **pull request**, even if you've never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed. + +Welcome to writing Python outside the browser. 🎓 + + diff --git a/examples/resume-tailor-agent/.env.example b/examples/resume-tailor-agent/.env.example new file mode 100644 index 0000000..f350a5a --- /dev/null +++ b/examples/resume-tailor-agent/.env.example @@ -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= diff --git a/examples/resume-tailor-agent/.gitignore b/examples/resume-tailor-agent/.gitignore new file mode 100644 index 0000000..5297e57 --- /dev/null +++ b/examples/resume-tailor-agent/.gitignore @@ -0,0 +1,4 @@ +.venv +__pycache__ +*.pyc +.env diff --git a/examples/resume-tailor-agent/.python-version b/examples/resume-tailor-agent/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/examples/resume-tailor-agent/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/examples/resume-tailor-agent/README.md b/examples/resume-tailor-agent/README.md new file mode 100644 index 0000000..8112995 --- /dev/null +++ b/examples/resume-tailor-agent/README.md @@ -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: + + +[![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. diff --git a/examples/resume-tailor-agent/notebook.ipynb b/examples/resume-tailor-agent/notebook.ipynb new file mode 100644 index 0000000..8303f68 --- /dev/null +++ b/examples/resume-tailor-agent/notebook.ipynb @@ -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 +} \ No newline at end of file diff --git a/examples/resume-tailor-agent/pyproject.toml b/examples/resume-tailor-agent/pyproject.toml new file mode 100644 index 0000000..bcc78f3 --- /dev/null +++ b/examples/resume-tailor-agent/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "resume-tailor-agent" +version = "0.1.0" +description = "A CLI that tailors a resume and drafts a cover letter for a specific job description with a free-tier LLM, the fuller companion to the course's Resume & Cover-Letter Tailoring Agent project." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "python-dotenv>=1.2.2", + "openai>=2.8.0", +] diff --git a/examples/resume-tailor-agent/sample_job.txt b/examples/resume-tailor-agent/sample_job.txt new file mode 100644 index 0000000..98684b5 --- /dev/null +++ b/examples/resume-tailor-agent/sample_job.txt @@ -0,0 +1,39 @@ +# Sample Job Description -- Junior Machine Learning Engineer + +## About the role +We're looking for a Junior Machine Learning Engineer to join our data team of +six. You'll work alongside senior engineers to build and ship machine learning +features that improve how our retail customers shop online. This is a +build-focused role: you'll write production Python, train and evaluate models, +and get code reviewed and deployed by a real team. + +## What you'll do +- Train, evaluate, and tune machine learning models (classification and + regression) on real customer and sales data. +- Write clean, tested Python in a shared codebase reviewed by senior engineers. +- Build data pipelines in Python and SQL to prepare features for models. +- Turn model results into dashboards and reports that non-technical + stakeholders can actually understand. +- Collaborate with analysts and product managers to define what "good" looks + like before building. + +## What we're looking for +- Working knowledge of Python and one or more of: scikit-learn, pandas, numpy. +- Some experience with SQL for querying and shaping data. +- Basic understanding of evaluation: what accuracy, precision, and recall are, + and when each one matters. +- Comfort presenting findings to people who are not data people. +- Bonus: experience with A/B testing, Git, or any cloud platform. + +## Nice to have +- Any deployed project you can point to -- a notebook, a GitHub repo, anything + you built yourself and can talk about. + +## Why join us +We value honest, clearly-explained work over impressive-sounding results. +There is no fake-it-till-you-make-it here -- we'd rather hire someone who +candidly says "I don't know yet" than someone who overstates their skills. + +## Apply +Send your resume and a short note about a data project you're proud of to +careers@example-store.example. diff --git a/examples/resume-tailor-agent/sample_resume.txt b/examples/resume-tailor-agent/sample_resume.txt new file mode 100644 index 0000000..be9c201 --- /dev/null +++ b/examples/resume-tailor-agent/sample_resume.txt @@ -0,0 +1,43 @@ +# Sample Resume -- Alex Rivera + +## Summary +Data analyst with 2 years of experience turning raw data into decisions for a +regional retail chain. Comfortable with pandas, SQL, and building dashboards +that non-technical colleagues actually use. Looking to grow into a role with +more machine learning. + +## Experience + +### Data Analyst -- Northwind Retail, Portland OR (2024 - present) +- Built a daily sales dashboard in Python (pandas + matplotlib) that regional + managers check every morning. +- Wrote and maintained SQL queries against a PostgreSQL warehouse, cutting + weekly reporting time from a day to under an hour. +- Cleaned and joined messy export files from three different store systems + into one weekly dataset for the finance team. +- Ran A/B tests on two store layouts and presented results to store managers. + +### Junior Data Analyst -- Local Radio Group, Portland OR (2022 - 2024) +- Automated a weekly listener-numbers report with Python, replacing a manual + Excel workflow. +- Responded to ad-hoc data requests from sales and programming teams. +- Learned pandas, matplotlib, and basic SQL on the job, mostly by doing. + +## Education + +- B.S. in Economics, Portland State University (2022) + +## Skills + +- Python (pandas, matplotlib, requests, Jupyter) +- SQL (PostgreSQL, SQLite) +- Excel / Google Sheets +- Basic statistics (hypothesis testing, A/B testing) +- Git and GitHub basics + +## Projects + +- Personal habit-tracker dashboard (pandas + matplotlib) -- a weekly calendar + heatmap of my own exercise habits. +- Web scraper for local event listings (requests + BeautifulSoup) -- published + a cleaned weekly dataset on GitHub. diff --git a/examples/resume-tailor-agent/tailor.py b/examples/resume-tailor-agent/tailor.py new file mode 100644 index 0000000..501d043 --- /dev/null +++ b/examples/resume-tailor-agent/tailor.py @@ -0,0 +1,173 @@ +"""Resume & Cover-Letter Tailoring Agent -- a CLI that tailors a resume and +drafts a cover letter for a specific job description, using a free-tier LLM. + +See docs/projects/resume-tailor-agent/index.md for the walkthrough this +file accompanies. + +You're free to use whichever free-tier provider you like -- this isn't +locked to any one of them. Set LLM_PROVIDER in a .env file (copy +.env.example) or a real environment variable to pick one; see PROVIDERS +below for the full list and which API key each one needs. Defaults to +"github" (GitHub Models) since it's free with no separate signup, tied to +a GitHub account every student here already has. + +Never hardcode a real API key here or commit one to the repo. + +Usage: + uv run python tailor.py sample_resume.txt sample_job.txt + uv run python tailor.py my_resume.txt my_job.txt --provider groq +""" + +import argparse +import os +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +load_dotenv() # reads a local .env file, if present; real env vars always win + +# Documents longer than this get truncated before being sent to the model -- +# see the "overlong documents" pitfall in the lesson for why this matters: +# free-tier context windows and per-request token quotas are both limited. +MAX_TEXT_CHARS = 30_000 + +SYSTEM_PROMPT = """\ +You are a meticulous, honest resume-and-cover-letter tailoring assistant. + +You will be given a RESUME and a JOB DESCRIPTION. Your job is to help the +candidate apply for THIS job, using ONLY facts that already exist in their +resume. This is non-negotiable: + +- NEVER invent skills, technologies, tools, titles, employers, projects, + dates, numbers, or credentials that are not already on the resume. +- NEVER reword an existing bullet point into something that is not plainly + supported by it. Rephrase and re-order freely, but do not upgrade. +- If the resume is missing something the job clearly asks for, say so in + the resume edits list instead of pretending the candidate has it. + +Produce exactly three sections: + +1. MATCH SCORE: A number from 0-100 with a two-sentence rationale. Be + honest -- an 82 with a clear explanation beats a 95 that can't be backed + up by the resume. + +2. COVER LETTER DRAFT: A complete, ready-to-edit cover letter of 2-3 short + paragraphs, addressed to a hiring manager, that connects specific items + already on the resume to the specific requirements of THIS job. Every + claim it makes must trace back to the resume. + +3. RESUME EDITS: A numbered list of concrete, actionable changes to make + to the resume for this job -- reordering bullets, swapping which + projects get highlighted, removing irrelevant lines, adding keywords + that genuinely match existing experience. Each edit states what to + change and why. Where the resume genuinely lacks something the job + wants, state that plainly as a gap, never as a fake achievement. + +Be specific and concrete throughout. Do not pad. Do not flatter. +""" + + +def load_text(path: str | Path) -> str: + """Reads a plain-text file and returns its contents as a single string.""" + return Path(path).read_text(encoding="utf-8") + + +def truncate(text: str, max_chars: int = MAX_TEXT_CHARS) -> str: + """Cuts an oversized document down to a size that fits a free-tier context window. + + Keeps the front of the document (usually the most information-dense + part) and appends a clear marker so the model -- and you -- know the + tailoring was based on a partial view, rather than silently trimming. + """ + if len(text) <= max_chars: + return text + return text[:max_chars] + f"\n\n... [truncated -- {len(text) - max_chars} more characters not shown] ..." + + +def _build_github_client() -> OpenAI: + return OpenAI(api_key=os.environ["GITHUB_TOKEN"], base_url="https://models.github.ai/inference") + + +def _build_gemini_client() -> OpenAI: + # Gemini exposes an OpenAI-compatible endpoint, so the same openai client + # works here too, just with a different base_url and key. + return OpenAI( + api_key=os.environ["GOOGLE_API_KEY"], + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) + + +def _build_groq_client() -> OpenAI: + return OpenAI(api_key=os.environ["GROQ_API_KEY"], base_url="https://api.groq.com/openai/v1") + + +def _build_mistral_client() -> OpenAI: + return OpenAI(api_key=os.environ["MISTRAL_API_KEY"], base_url="https://api.mistral.ai/v1") + + +def _build_cerebras_client() -> OpenAI: + return OpenAI(api_key=os.environ["CEREBRAS_API_KEY"], base_url="https://api.cerebras.ai/v1") + + +def _build_openrouter_client() -> OpenAI: + return OpenAI(api_key=os.environ["OPENROUTER_API_KEY"], base_url="https://openrouter.ai/api/v1") + + +# Every provider here is free-tier at the time of writing, with no credit +# card required -- but check the provider's own pricing page before relying +# on that, since free tiers change. Each tuple is (client builder, model ID). +PROVIDERS = { + "github": (_build_github_client, "gpt-4o-mini"), + "gemini": (_build_gemini_client, "gemini-3.5-flash"), + "groq": (_build_groq_client, "llama-3.3-70b-versatile"), + "mistral": (_build_mistral_client, "mistral-small-latest"), + "cerebras": (_build_cerebras_client, "llama-3.3-70b"), + "openrouter": (_build_openrouter_client, "meta-llama/llama-3.3-70b-instruct:free"), +} + + +def tailor(resume: str, job: str, provider: str | None = None) -> str: + """Sends a resume + job description to a free-tier LLM and returns the tailored result.""" + provider = provider or os.environ.get("LLM_PROVIDER", "github") + if provider not in PROVIDERS: + raise ValueError(f"Unknown LLM_PROVIDER '{provider}'. Choose one of: {', '.join(PROVIDERS)}") + build_client, model = PROVIDERS[provider] + client = build_client() + + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"RESUME:\n```\n{truncate(resume)}\n```\n\nJOB DESCRIPTION:\n```\n{truncate(job)}\n```", + }, + ], + ) + return response.choices[0].message.content + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Tailor a resume and draft a cover letter for a specific job description." + ) + parser.add_argument("resume", help="Path to your resume as a .txt or .md file.") + parser.add_argument("job", help="Path to the job description as a .txt or .md file.") + parser.add_argument("--provider", help="Override LLM_PROVIDER for this run, e.g. 'groq'.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + resume = load_text(args.resume) + job = load_text(args.job) + + print(f"Loaded resume: {len(resume)} characters") + print(f"Loaded job description: {len(job)} characters\n") + print("Generating match score, cover letter draft, and resume edits...\n") + print(tailor(resume, job, provider=args.provider)) + + +if __name__ == "__main__": + main() diff --git a/examples/resume-tailor-agent/uv.lock b/examples/resume-tailor-agent/uv.lock new file mode 100644 index 0000000..e6cc452 --- /dev/null +++ b/examples/resume-tailor-agent/uv.lock @@ -0,0 +1,341 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "openai" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/5a/c45fa035cd72c70ebe67c6e079e3adf871492382634f69e3dff62c43597d/openai-2.52.0.tar.gz", hash = "sha256:7c736d592f81471ce1f734838390983c4d8c8aecff23dcd36e600a58e5032d9c", size = 1098876, upload-time = "2026-07-31T15:13:03.228Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ac/ceb40c995df49533ad4dcff6c37f0d85cf14446a212363fc9d2f927e60b4/openai-2.52.0-py3-none-any.whl", hash = "sha256:f97e231d9a8fa69ab55897df1080f02d99913fb0a30e3ee56ea16a1eb6c2d434", size = 1659569, upload-time = "2026-07-31T15:13:01.145Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "resume-tailor-agent" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "openai" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "openai", specifier = ">=2.8.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/src/data/projects.ts b/src/data/projects.ts index 1cad01e..de3e3cc 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -19,6 +19,12 @@ export interface ProjectMeta { * and src/pages/index.tsx for where those get merged in. */ export const PROJECTS: ProjectMeta[] = [ + { + id: 'resume-tailor-agent', + date: '2027-08', + url: '/docs/projects/resume-tailor-agent', + tags: ['AI Agents', 'Career', 'Automation'], + }, { id: '2027-dependency-freshness-checker', date: '2027-08', diff --git a/src/pages/index.tsx b/src/pages/index.tsx index c1ed17a..c74f6ce 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -190,6 +190,25 @@ function RealWorldProjects() {

+ + 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. + + } + />