Optional: Enriching with an LLM
This chapter is optional. Nothing here is required for Week 15's deliverables or the assignment. Build it only when your required pipeline runs green in production and your team wants to go further.
<aside>
📝 LiteLLM (this cohort): The class uses a shared LiteLLM gateway (not OpenRouter). Each team has its own budgeted virtual key. Iterate on prompts in a local notebook, then enable the dbt Python model on Databricks. Deep setup lives in your repo under data/optional/llm_classify/ and data/optional/python_model/.
</aside>
Your starter repo already classifies job titles into disciplines. fct_postings_enriched.py does it with a dictionary of keywords: "data engineer" means data, "kubernetes" means devops. That model is required and it works.
This chapter swaps the rules for a language model. The interesting part is what stays the same. Both production paths are dbt Python models on serverless compute, so the difference is the rules, not the plumbing. What changes for development is a laptop notebook, so you are not paying serverless minutes while you argue about labels.
By the end of this chapter, you should be able to:
cheap / medium / strong) and check spend against the daily budgetYou have built containers all track, so a container is the obvious reflex. It is the wrong one here, and the reason is dependency order.
dbt already knows this table depends on fct_postings. That single fact buys you three things:
dbt build runs it in the right order, so it cannot read yesterday's martref() resolves it like any other model.yml as everything elseA separate container would need its own trigger, its own wait, and its own answer to "did the thing I read finish building?". You would be rebuilding dbt's dependency graph in Airflow, by hand, for one model.
What you give up is speed while developing. A serverless run takes one to two minutes (often two to three with LLM batches), against a few seconds for a local notebook cell. That is fine daily and annoying while iterating. It is exactly why prompt work starts in optional/llm_classify/, not in dbt build.
<aside> 🤓 Curious Geek: Python models are newer than you think
dbt was SQL-only for its first six years. Python models arrived in 2022, once warehouses could run Python next to the data instead of shipping the data to Python. That ordering is why dbt.ref() in a Python model returns a DataFrame that never left the warehouse.
</aside>
You cannot run the dbt Python model as local Python. Databricks always executes it on serverless. So the repo ships two places that share a prompt contract, not an import:
Notebook (optional/llm_classify/) |
dbt fct_title_discipline |
|
|---|---|---|
| Where it runs | Your laptop | Databricks serverless |
| LiteLLM key | az login (HYF) → Key Vault litellm-key-team-<letter>, or LITELLM_API_KEY |
dbutils.secrets → litellm-api-key (already provisioned) |
| Titles in | SQL sample you write (for example from staging) | Distinct titles from ref("fct_postings") |
| Batch size | Small (often 20) for fast iteration | Larger (often 200) for fewer API calls; very large batches risk truncated JSON |
| Output column | Your choice in the notebook (for example job_categories) |
discipline (fixed schema + tests) |
| Shared contract | PROMPT + category list in the notebook |
Same PROMPT constant pasted into the .py model |
Serverless does not ship your optional/ package, so do not try to import the notebook helpers from models/. Copy the PROMPT string (and categories in that prompt) by hand when labels look good. Full file map and VS Code kernel steps: data/optional/llm_classify/README.md.
Read these before you enable the dbt model, because getting any of them wrong turns a rounding error into a bill worth explaining.
| Decision | Why |
|---|---|
| One row per distinct title | Thousands of postings are hundreds of titles, and the answer for a title never depends on which posting it came from |
| Incremental | A run only pays for titles it has never seen. The first run classifies everything, the second usually classifies a handful |
| Batched | One request covers many titles instead of one |
Drop any of the three and you are calling an API once per posting per day. On the sample source that is cents. On a real one, it is the difference between nothing and a number your teacher asks about.
other instead of storing what the model said?A dictionary gives the same answer forever. A language model does not. That has a real consequence: accepted_values can fail on a day when your data did not change at all.
Two things in the shipped code keep that in check:
temperature: 0 set on every request, so two runs over the same titles agreeother<aside>
⚠️ Check that list against the dictionary model before you switch. The two must name the same disciplines, or the ones only the dictionary knows about quietly become other: accepted_values allows other, so nothing fails and the category simply stops appearing in your app.
</aside>
Pin the model alias too: cheap, medium, or strong. Those names stay stable; the Azure deployment behind them is the teacher's problem.
LiteLLM is an OpenAI-compatible proxy in front of Azure OpenAI. All final-project teams share one gateway. Both the notebook and the dbt model post JSON to /v1/chat/completions. What differs per team is only the virtual key (and therefore the daily spend budget).
| Piece | Value |
|---|---|
| Chat endpoint (all teams) | https://app-litellm-team-d.blacksky-9263d113.westeurope.azurecontainerapps.io/v1/chat/completions |
| Secret in Databricks | scope team_<your letter>, key litellm-api-key (already provisioned) |
| Model aliases | cheap, medium, strong |
Pick one with +llm_model: in dbt_project.yml (and the same id in the notebook). Start on cheap. Use strong only on tiny samples when judging label quality: it burns the daily budget faster. medium is the same cost tier as cheap in this pilot; treat it like cheap today. The Azure deployment behind each alias is the teacher's problem.
Each team's virtual key is capped separately. Crossing the budget returns an error until the reset time (UTC midnight for this pilot).
| Limit | Value |
|---|---|
| Max spend | $5 per day |
| Requests | 10 per minute (rpm_limit) |
| Tokens | 20,000 per minute (tpm_limit) |
That is why the three design decisions above still matter. Batching and incremental runs keep you under rpm/tpm. Choosing cheap keeps spend under $5.
Two habits keep you out of trouble:
Run the first Databricks backfill by hand, once. The scheduled task retries, and a failed incremental run writes nothing. So every batch is paid for again on the retry. Run it yourself and watch it finish. Let the schedule pick up from there, when there are only a few new titles a day.
Read the error before changing anything. A budget or rate-limit refusal names the cause. If it says the budget is spent, wait for reset or ask your teacher. Do not edit the code to "try harder".
Check remaining budget with the team key (/key/info). Ask your teacher for the key if it is not already in your shell: it is the same material as Key Vault litellm-key-team-<letter> / Databricks litellm-api-key. Full request logs and the Admin UI need the teacher master key (litellm-master-key-team-d; Optional matrix).
curl -sS \
-H "Authorization: Bearer $LITELLM_TEAM_KEY" \
"https://app-litellm-team-d.blacksky-9263d113.westeurope.azurecontainerapps.io/key/info" \
| python3 -m json.tool
Read info.spend, info.max_budget, and info.budget_reset_at.
<aside> 💡 First call after idle can take about a minute (sometimes longer): the Container App scales to zero, then boots Postgres + LiteLLM. Default HTTP timeout in the repo helpers is generous (~300s). Retry once before debugging your prompt.
</aside>
Mechanical detail (VS Code kernel, uv sync --extra notebook, cold starts) lives in data/optional/llm_classify/README.md. Enabling and joining the dbt model: data/optional/python_model/README.md. This section is the ordered path.
Do not touch the LLM path until fct_postings_enriched builds green. The LLM table is optional enrichment on top of a working mart.
Install everything this walkthrough needs in one go. uv sync --extra notebook alone drops pytest and dbt from the venv, so the later steps would fail.
cd data
uv sync --extra notebook --extra dbt --extra dev
az login # HYF tenant, so Key Vault can supply the LiteLLM key
Open data/optional/llm_classify/llm_classify_dev.ipynb in VS Code. Select the data/.venv kernel (see the llm_classify README if it is missing).
You do not paste the LiteLLM key into .env for this path. The notebook loads Key Vault litellm-key-team-<letter> via Azure CLI (or LITELLM_API_KEY if a teacher gave you that override). The letter comes from DATABRICKS_CATALOG in .env (team_c → litellm-key-team-c). Run az login against the HYF tenant (default profile ~/.azure). If Key Vault still fails after that, ask your teacher, or set LITELLM_API_KEY for this session.
Databricks settings in data/.env are still needed so the notebook can run sample SQL (DATABRICKS_HOST, DATABRICKS_HTTP_PATH, DATABRICKS_TOKEN, DATABRICKS_CATALOG, DBT_SCHEMA).
In the notebook:
SAMPLE_N small at first). The shipped query reads team_<letter>.analytics.stg_postings; change the catalog if needed.list_models() to see cheap / medium / strong. The first LiteLLM call after idle can take about a minute while the gateway wakes up.CATEGORIES, PROMPT (keep {numbered_items}), MODEL, BATCH_SIZE.classify(...) and read the table out loud with a teammate.That judgement is the point of this step. If you would not defend the labels, do not enable dbt yet.
Paste the notebook PROMPT into the PROMPT constant in dbt/models/marts/fct_title_discipline.py. Keep {numbered_items} and the same category list. Do not copy llm_local.py into models/: the dbt file has its own thin chat helper for serverless.
cd data
uv run pytest tests/dbt/test_fct_title_discipline.py tests/optional/test_llm_local.py
These cover batching and parsing. They do not call LiteLLM. Needs the dev extra from Step 2 (pytest is not in the notebook-only sync).