AiCore logo

Keeping Sensitive Data On-Premise: Local Model Inference with Ollama

Module 5, Unit 3 | Lesson 3 of 5

By the end of this lesson, you will be able to:

  • Explain why PII-flagged records cannot be sent to a cloud API and what the alternative is (K11, B6)
  • Install Ollama and pull Qwen3 1.7B for local model inference (K12, S10)
  • Write a local classifier using the Ollama REST API that mirrors the OpenAI classifier interface from L3.1 (K12, S11)
  • Update your pipeline to route PII-flagged records to the local classifier and all others to OpenAI (K12, S10, S11)

In L3.1 you built a classifier that calls the OpenAI API. It works — but there is a problem. Some records in your dataset have flagged_issue == "pii_detected". Sending those records to OpenAI means transmitting learner PII — identifiers, sensitive usage data — to a third-party cloud service. Depending on your organisation's data processing agreement, that may not be permitted.

The solution is to run a local model for those records. The data never leaves your machine.

🔑 Key term — Local model inference: Running a language model on your own hardware, rather than sending a request to a remote API. Local inference means data stays on-premise — no network call, no third-party data processing. The trade-off is that local models are typically smaller and less capable than frontier cloud models, and you need hardware capable of running them.

What is Ollama?

Ollama is an open-source tool that lets you run language models locally with a single command. It handles model download, quantisation (compressed model files that fit in available GPU or CPU memory), and exposes a local HTTP REST API that mirrors the OpenAI API format. You can swap between models by changing a single string.

It is worth knowing what sits underneath it. The actual inference is done by llama.cpp, the C/C++ engine that made running these models on ordinary hardware practical in the first place; Ollama is the model management and serving layer wrapped around it. Models are distributed as GGUF files — one file holding the weights, the tokeniser and the metadata together — and llama.cpp can be used directly if you want control that Ollama abstracts away, such as specific quantisation levels or how layers are split between GPU and CPU. Its own llama-server also exposes an OpenAI-compatible endpoint, which matters for what you are about to build: the client code in this lesson talks to a local HTTP endpoint, so it works against either with a change of port. You are picking a serving layer here, not locking yourself into one.

Coach Cora

Coach Cora

Qwen3 1.7B is about 1.4 GB quantised and runs on a standard laptop CPU — no GPU required. It is far less capable than gpt-4.1-mini at open-ended reasoning, but this is a constrained classification task with four fixed labels and a well-structured prompt, which is exactly the shape small models handle well. If 1.4 GB is too much for your machine, gemma3:1b is around 815 MB and will also do this job. Your system prompt goes across to the local model unchanged — you are changing the infrastructure, not the task specification. What does change is the strength of the guarantee you get back, which is why the parsing below is written more defensively than the cloud path.

Before someone in your organisation asks: Qwen3 is a Chinese model. It comes from Alibaba, and that is worth being precise about rather than hand-waving past. The concern people usually have — data going to a Chinese company, or falling under a foreign jurisdiction — is a concern about a hosted API. It does not apply here. Ollama downloads the weights once and runs them on your own machine, offline; no record, no prompt and no telemetry goes anywhere. That is the entire reason this lesson uses a local model for PII in the first place.

Two real considerations remain. The first is supply chain: you are running third-party weights, so pull them from the official registry and pin the tag, exactly as you would for any dependency. The second is that models trained under different regulatory regimes will deflect or refuse on politically sensitive topics — irrelevant to sorting AI usage logs into four labels, potentially very relevant if you reuse the same model for open-ended text. If your organisation's policy rules out Chinese-origin models regardless, gemma3:1b (Google) and llama3.2:1b (Meta) are drop-in replacements: change the model string, change nothing else.

Install Ollama and pull the model

Download Ollama from ollama.com and install it. Also install the requests library if you haven't already:

pip install requests

Then open a terminal and pull the model:

ollama pull qwen3:1.7b

# Tighter on memory? This one is about 815 MB and works here too.
# ollama pull gemma3:1b

Verify it is available:

ollama list
# Expected: qwen3:1.7b   ...   1.4 GB  ...

Start the Ollama server (it usually auto-starts after install, but you can start it manually):

ollama serve

Test it works with a quick request:

curl http://localhost:11434/api/chat -d '{
  "model": "qwen3:1.7b",
  "messages": [{"role": "user", "content": "Say hello in one word."}],
  "stream": false
}'

You should get a JSON response with the model's reply.

🔑 Key term — Quantisation: A technique for compressing model weights from full precision (32-bit floats) to lower precision (4-bit or 8-bit integers), dramatically reducing how much memory a model needs. Qwen3 1.7B downloads at roughly 1.4 GB under Ollama's default Q4_K_M quantisation — mixed 4-bit, which keeps some layers at higher precision, so it averages nearer 5 bits per weight than 4. The same weights at full 32-bit precision would be about 8 GB. (Check the numbers rather than trusting the name: this model is marketed as 1.7B but the GGUF reports just over 2 billion parameters, which is where the 8 GB comes from.) For comparison, a 3B model such as Llama 3.2 3B is about 2 GB quantised against roughly 12 GB at full precision. Quantised models are slightly less accurate, but for a four-label classification task the difference is usually negligible.

Which memory matters depends on where it runs. If you have a supported GPU, Ollama loads the weights into VRAM — the memory on the graphics card itself — and that is the binding constraint, because a model larger than your VRAM either spills layers back to the CPU and slows sharply or fails to load. On a machine with no usable GPU it runs on the CPU and the same weights sit in ordinary system RAM instead, which is usually more plentiful and considerably slower. So "needs 1.4 GB" means 1.4 GB of VRAM on a GPU box and 1.4 GB of RAM on a CPU-only one, and the number to check is whichever your machine will actually use.

Write the local classifier

First, extract what the two classifiers share

Before writing the local classifier, do a small refactor. Create classification_contract.py and move CLASSIFICATION_LABELS, SYSTEM_PROMPT, CONFIDENCE_THRESHOLD and validate_result out of classifier.py and into it. Nothing in them changes except validate_result, which gains a source argument now that two providers can raise the same error:

# classification_contract.py — no provider, no client, no network.
# CLASSIFICATION_LABELS and SYSTEM_PROMPT move here from classifier.py, unchanged.

CONFIDENCE_THRESHOLD = 0.75


def build_user_message(record_data: dict) -> str:
    """The user turn, identical for both providers."""
    return f"""Classify this AI usage log record:

log_id: {record_data.get('log_id')}
tool_name: {record_data.get('tool_name')}
task_type: {record_data.get('task_type')}
flagged_issue: {record_data.get('flagged_issue')}
cost_usd: {record_data.get('cost_usd')}
time_saved_mins: {record_data.get('time_saved_mins')}
quality_score: {record_data.get('quality_score')}
human_reviewed: {record_data.get('human_reviewed')}"""


def validate_result(result: dict, source: str) -> dict:
    """The output guards from L3.1, now shared by both providers."""
    if result.get("classification") not in CLASSIFICATION_LABELS:
        raise ValueError(
            f"[{source}] Unknown classification: {result.get('classification')!r}. "
            f"Expected one of: {CLASSIFICATION_LABELS}"
        )
    confidence = result.get("confidence")
    if not isinstance(confidence, (int, float)) or not 0.0 <= confidence <= 1.0:
        raise ValueError(f"[{source}] Missing or invalid confidence score")
    if not isinstance(result.get("reasoning"), str) or not result["reasoning"].strip():
        raise ValueError(f"[{source}] Missing or invalid reasoning")
    return result


def apply_threshold(result: dict) -> dict:
    """The confidence threshold and human-review fallback from L3.1."""
    if result["confidence"] < CONFIDENCE_THRESHOLD:
        result["route_override"] = "human_review"
        result["route_reason"] = (
            f"Confidence {result['confidence']:.2f} below "
            f"threshold {CONFIDENCE_THRESHOLD}"
        )
    else:
        result["route_override"] = None
        result["route_reason"] = None
    return result

build_user_message and apply_threshold are new here only in the sense of having names — they are the f-string and the threshold block that were already sitting inside classify_record and classify_with_fallback, lifted out so the local classifier does not have to copy them.

Then rewire classifier.py: delete what you moved and import it back.

# classifier.py — replace the definitions you moved with this import
from classification_contract import (
    SYSTEM_PROMPT,
    CLASSIFICATION_LABELS,
    CONFIDENCE_THRESHOLD,
    build_user_message,
    validate_result,
    apply_threshold,
)

classify_with_fallback collapses to a single line, because everything it used to do now has a name:

def classify_with_fallback(record_data: dict[str, Any]) -> dict[str, Any]:
    """Classify a record and apply the confidence threshold."""
    return apply_threshold(classify_record(record_data))

Two things inside classify_record change with it: the f-string that built user_message becomes a call to build_user_message, and validate_result now says which provider raised. Everything else is as you left it in L3.2.

classify_record after the refactorclassifier.py, 31 lines
def classify_record(record_data: dict[str, Any]) -> dict[str, Any]:
    """
    Send a single record to the LLM for classification.
    Returns a dict with classification, confidence, reasoning and token counts.
    Raises ValueError if the response cannot be parsed or is invalid.
    """
    response = get_client().chat.completions.create(
        model="gpt-4.1-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": build_user_message(record_data)},
        ],
        temperature=0.1,
        max_completion_tokens=200,
        response_format={"type": "json_schema", "json_schema": CLASSIFICATION_SCHEMA},
    )
    choice = response.choices[0]

    if choice.message.refusal:
        raise ValueError(f"Model refused to classify: {choice.message.refusal}")
    if choice.finish_reason == "length":
        raise ValueError("Response truncated before the JSON was complete")

    raw = choice.message.content
    if not raw:
        raise ValueError("Model returned an empty response")

    result = validate_result(json.loads(raw), "openai")
    usage = response.usage
    result["_api_tokens_in"] = usage.prompt_tokens
    result["_api_tokens_out"] = usage.completion_tokens
    return result

This is worth two minutes of your time, and the reason is not tidiness. Had local_classifier.py imported its constants straight from classifier.py, then importing the module whose entire purpose is never contacting a cloud provider would pull in the openai SDK and the whole cloud code path with it. That machine now needs the cloud dependency installed to run the local model, and a reader tracing what local_classifier.py touches finds a cloud provider at the end of the trail. The coupling would be invisible until the day you deployed the local path to a locked-down machine and discovered it had a cloud SDK in its import graph.

The shared thing here is the contract — the labels, the prompt, the threshold. Both providers depend on the contract; neither depends on the other. That is what "same interface, different implementation" actually requires in code.

Now the local classifier

Create local_classifier.py. The interface mirrors classifier.py exactly — same function names, same return shape, same validation. The only difference is the endpoint and model:

from __future__ import annotations

import json
from typing import Any

import requests

OLLAMA_BASE_URL = "http://localhost:11434"
LOCAL_MODEL = "qwen3:1.7b"

# Shared prompt, labels, threshold and guards — see the note above on why these
# live in their own module rather than being imported from classifier.py
from classification_contract import (
    SYSTEM_PROMPT,
    CLASSIFICATION_LABELS,
    CONFIDENCE_THRESHOLD,
    build_user_message,
    validate_result,
    apply_threshold,
)

One function, and only two things in it are genuinely new: the request is a plain HTTP POST to localhost instead of the OpenAI SDK, and reading the reply takes more care. Everything else — the prompt, the user message, the four output guards — comes from the shared contract.

def classify_record_local(record_data: dict[str, Any]) -> dict[str, Any]:
    """
    Classify a single record using the local Ollama model.
    Same prompt and same validation as classify_record().
    Data never leaves the machine.
    """
    response = requests.post(
        f"{OLLAMA_BASE_URL}/api/chat",
        json={
            "model": LOCAL_MODEL,
            "messages": [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": build_user_message(record_data)},
            ],
            "stream": False,
            "format": "json",
            # Qwen3 has a thinking mode and it is ON by default. Left on, a 1.7B
            # model reasons through every record on the CPU and will run past the
            # timeout below — and on older Ollama builds the <think> block lands
            # in `content`, where json.loads chokes on it. Classification does
            # not need it. (gemma3:1b has no thinking mode; this is a no-op there.)
            "think": False,
            "options": {"temperature": 0.1},
        },
        timeout=60,  # Local models are slower — allow more time
    )
    response.raise_for_status()

    payload = response.json()
    raw = payload.get("message", {}).get("content", "")
    if not raw:
        raise ValueError(f"Local model returned an empty response: {payload!r}")
    raw = raw.strip()

    # Local models sometimes wrap JSON in markdown code fences — strip them
    if raw.startswith("```"):
        raw = raw.split("```")[1]
        if raw.startswith("json"):
            raw = raw[4:]
        raw = raw.strip()

    try:
        result = json.loads(raw)
    except json.JSONDecodeError as e:
        raise ValueError(f"Local model returned invalid JSON: {raw!r}") from e

    validate_result(result, "ollama")
    # Ollama reports token counts, but they cost you compute rather than money,
    # so the cost arithmetic in stage_classify sees zero from this path.
    result["_api_tokens_in"] = 0
    result["_api_tokens_out"] = 0
    return result

Two fields in that request matter more than they look, and the comment on think explains the first. The second is format: "json", which constrains the syntax of the reply and nothing else — it does not know your four labels, which is precisely why validate_result still runs. The fence-stripping block is defensive redundancy; the Curious Cat box below says when it earns its keep.

And finally the threshold wrapper, which is the moment the refactor pays for itself. It mirrors classify_with_fallback in classifier.py so stage_classify can call either one without knowing which it got — and because the threshold logic now has a name in the shared module, it is one line rather than eighteen duplicated ones:

def classify_with_fallback_local(record_data: dict[str, Any]) -> dict[str, Any]:
    """Classify a PII-flagged record locally, then apply the confidence threshold."""
    return apply_threshold(classify_record_local(record_data))

Two functions, two providers, one policy. If the threshold moves from 0.75 to 0.8, you change it in classification_contract.py and both paths follow — which is the difference between a shared contract and two files that happen to agree today.

Curious Cat

Curious Cat

Why strip markdown code fences here when you did not need to for OpenAI? Look closely and you will notice the request already sets "format": "json", which tells Ollama to constrain decoding so the reply is well-formed JSON — so in theory a fence cannot appear and this block is dead code. Keep it anyway. It costs four lines and it covers the cases the guarantee does not: an older Ollama that ignores the field, a model whose template handles it badly, or a future change to how you call it. That is the honest difference from L3.1. There you had a documented provider contract with strict: True; here you have a local runtime you upgrade yourself, enforcing a weaker promise — well-formed JSON, but nothing about your four labels or your confidence range. Ollama will accept a full JSON schema in format rather than the string "json", which narrows the gap and is worth doing once you are on a version that supports it. Even then, validate_result stays: it is the only check that both providers run.

Route PII records to the local classifier

A diagram titled "Three paths out of stage_classify", subtitled that the routing rule is the governance control and that where a record goes is decided by code, before any model sees it. On the left, a box labelled valid_records holds 30 records leaving the validate stage. Three arrows branch from it. The first path tests flagged_issue equals NONE, described as no issue to classify, carrying 24 records, and leads to a grey box labelled NO MODEL CALLED which sets classification to "none" and confidence to 1.0. The second path tests flagged_issue equals PII_DETECTED, described as must not leave the machine, carrying 2 records, and leads to a green box labelled LOCAL — OLLAMA running qwen3:1.7b on localhost port 11434 with no network egress. The third path tests for HALLUCINATION or COST_OVERRUN, described as cloud-safe and needing interpretation, carrying 4 records, and leads to a blue box labelled CLOUD — OPENAI running gpt-4.1-mini, billed per token, leaving the machine. A closing note observes that this is six model calls for thirty records, that the 24 clean records are the cheapest optimisation in the module because the filter runs before the call so they cost nothing and change nothing, and advises always asking what you can decide in code before you ask a model.

Replace classify_item and stage_classify in stages.py with the versions below — they supersede the L3.1 implementations. Delete the previous ones before pasting these in.

Read what carries forward, carefully. Everything you added in L3.2 is still here: the _api_tokens_in/_api_tokens_out pass-through, the post-hoc sums, estimate_cost and the MAX_TOKENS_PER_RUN guardrail. classify_with_escalation is not, and that is deliberate — it was a demonstration of the escalation pattern, never wired into the stage; if you wired it into yours, keep your version. This is the trap in every "replace the whole function" instruction, and it is worth naming: when a later lesson hands you a wholesale replacement, your job is to check what it quietly drops. Here you have been told. In code you did not write, nobody will tell you.

Start with the two imports and the guardrail constant:

from classifier import classify_with_fallback, estimate_cost
from local_classifier import classify_with_fallback_local
from schema import FlaggedIssue

MAX_TOKENS_PER_RUN = 500_000  # from L3.2 — configurable

Next, the routing decision — and it is made in code, before any model sees the record. PII goes to the local model and cannot leave the machine; everything else goes to the cloud. That is the governance control: not a prompt instruction, not a model's judgement. It gets its own function so it can be pointed at, tested, and shown to whoever asks where the PII went:

def classify_via_model(record: ToolUsageRecord) -> tuple[dict[str, Any], str]:
    """Send one flagged record to a provider. PII stays local. Returns (result, model)."""
    record_data = record_payload(record)

    if record.flagged_issue == FlaggedIssue.PII_DETECTED:
        result = with_retry(
            lambda: classify_with_fallback_local(record_data),
            max_attempts=3,
            base_delay=2.0,  # Local models are slower
        )
        return result, "ollama/qwen3:1.7b"

    result = with_retry(
        lambda: classify_with_fallback(record_data),
        max_attempts=3,
        base_delay=1.0,
    )
    return result, "openai/gpt-4.1-mini"

classify_item shrinks to the shape it always wanted: skip clean records, ask a provider about the rest, return one dict either way.

def classify_item(item: dict[str, Any]) -> dict[str, Any]:
    """Classify one record. Clean records never reach a model."""
    record = item["record"]

    if record.flagged_issue == FlaggedIssue.NONE:
        return {
            **item,
            "classification": "none",
            "confidence": 1.0,
            "reasoning": "No issue flagged - classification not required.",
            "route_override": None,
            "llm_called": False,
            "model_used": "none",
        }

    result, model_used = classify_via_model(record)

    return {
        **item,
        "classification": result["classification"],
        "confidence": result["confidence"],
        "reasoning": result["reasoning"],
        "route_override": result.get("route_override"),
        "route_reason": result.get("route_reason"),
        "llm_called": True,
        "model_used": model_used,
        # Token bookkeeping from L3.2. Local calls report zero — Ollama
        # returns token counts, but they cost you compute, not money.
        "_api_tokens_in": result.get("_api_tokens_in", 0),
        "_api_tokens_out": result.get("_api_tokens_out", 0),
    }

The counting moves into its own function, because there is now enough of it to be worth naming — and because counting after the workers have finished is the whole point:

def classify_metadata(results: list[dict[str, Any]]) -> dict[str, Any]:
    """What the classify stage did. Counted from results, not from the workers."""
    # No shared mutable state: every number here is derived from the returned
    # list. Note `skipped_clean` tests model_used rather than `not llm_called`.
    # They agree today, but L4.2 adds cache hits, which also have llm_called
    # False and would otherwise be miscounted as records that needed no model.
    prompt_tokens = sum(r.get("_api_tokens_in", 0) for r in results)
    completion_tokens = sum(r.get("_api_tokens_out", 0) for r in results)
    return {
        "cloud_calls": sum(1 for r in results if r.get("model_used") == "openai/gpt-4.1-mini"),
        "local_calls": sum(1 for r in results if r.get("model_used") == "ollama/qwen3:1.7b"),
        "skipped_clean": sum(1 for r in results if r.get("model_used") == "none"),
        "cloud_model": "gpt-4.1-mini",
        "local_model": "qwen3:1.7b",
        "prompt_tokens": prompt_tokens,
        "completion_tokens": completion_tokens,
        "total_tokens": prompt_tokens + completion_tokens,
        "estimated_cost_usd": estimate_cost(
            prompt_tokens, completion_tokens, "gpt-4.1-mini"
        ),
    }

And the stage itself is now short enough to read in one go. The guardrail from L3.2 is unchanged: it sets halted rather than raising. Since L2.3 a halted run writes no output file, so the reason has to survive somewhere else — the terminal now, and from L3.4 the structured log.

def stage_classify(state: PipelineState) -> PipelineState:
    """
    Stage 3: Classify flagged records.
    - PII-flagged records → local Ollama classifier (data stays on-premise)
    - All other flagged records → OpenAI classifier
    - Clean records → pass through without a model call
    """
    started = time.time()

    items = [{"record": r} for r in state.valid_records]
    results, errs = run_in_parallel(classify_item, items, max_workers=3)
    state.classified_records = results
    meta = classify_metadata(results)

    state.stage_results.append(StageResult(
        stage_name="classify",
        records_in=len(state.valid_records),
        records_out=len(results),
        records_failed=len(errs),
        duration_seconds=round(time.time() - started, 3),
        # run_in_parallel keeps the whole failed item, record object and all.
        # The output payload is JSON, so reduce each failure to the two facts a
        # recipient needs: which record, and what went wrong.
        errors=[
            {"log_id": e["item"]["record"].log_id, "error": e["error"]}
            for e in errs
        ],
        metadata=meta,
    ))

    # Cost guardrail from L3.2 — halt, do not raise
    if meta["total_tokens"] > MAX_TOKENS_PER_RUN:
        state.halted = True
        state.halt_reason = (
            f"Token guardrail exceeded: {meta['total_tokens']:,} tokens consumed "
            f"(limit {MAX_TOKENS_PER_RUN:,})"
        )
        print(f"[classify] HALTING: {state.halt_reason}")

    print(
        f"[classify] {len(results)} classified "
        f"({meta['cloud_calls']} cloud, {meta['local_calls']} local, "
        f"{meta['skipped_clean']} skipped clean, {len(errs)} errors)"
    )
    print(f"[classify] Estimated API cost: ${meta['estimated_cost_usd']:.6f}")
    return state

Only the cloud calls contribute tokens. classify_with_fallback_local returns no usage object, so result.get("_api_tokens_in", 0) is 0 for every PII record — which is exactly right. Ollama costs you compute and electricity, not API spend, and a cost figure that quietly billed you for local inference would be worse than no figure at all.

Test local inference in isolation

Before running the full pipeline, verify the local classifier works:

# quick_test_local.py
from dotenv import load_dotenv
load_dotenv()

from local_classifier import classify_record_local

result = classify_record_local({
    "log_id": "LOG-023",          # A real PII-flagged row from your CSV
    "tool_name": "Gemini Pro",
    "task_type": "data analysis",
    "flagged_issue": "pii_detected",
    "cost_usd": 0.034,
    "time_saved_mins": 24,
    "quality_score": 3.4,
    "human_reviewed": True,
})
print(result)
# Expected: {"classification": "compliance_risk", "confidence": ..., "reasoning": "..."}

If Ollama is not running, you will get a ConnectionRefusedError. Start it with ollama serve and retry. Then run the full pipeline:

python pipeline.py

The [classify] output should show both cloud and local counts. In pipeline_output.json, the stage metadata should include cloud_calls, local_calls, and both model names.

Build activity

  1. Install Ollama and pull qwen3:1.7b (or gemma3:1b if memory is tight). Verify with ollama list.
  2. Extract SYSTEM_PROMPT, CLASSIFICATION_LABELS and CONFIDENCE_THRESHOLD into classification_contract.py, and update classifier.py to import them from there.
  3. Create local_classifier.py with classify_record_local and classify_with_fallback_local.
  4. Test classify_record_local in isolation on LOG-023.
  5. Update stage_classify to route PII records to the local classifier.
  6. Run python pipeline.py and confirm the [classify] output shows separate cloud and local counts.
  7. Verify pipeline_output.json records both model names in the classify stage metadata.
  8. Prove the decoupling. Renaming your .env file will not prove it — since L3.1 the OpenAI client is built lazily, so importing classifier.py succeeds with no key present. Test the import graph instead:
python -c "import sys, local_classifier; assert 'openai' not in sys.modules, 'still coupled to the cloud module'; print('decoupled')"

If this raises AssertionError, something in local_classifier.py still reaches classifier.py. A test that cannot fail proves nothing — always check that yours can.

Challenge Chase

Challenge Chase

Compare the classifications produced by Qwen3 1.7B (local) and GPT-4.1-mini (cloud) on the same PII-flagged records. Run both classifiers on the same set and record the label, confidence, and reasoning side by side. Where do they agree? Where do they differ? What does the confidence spread tell you about each model's certainty? How would you decide which model's classification to trust when they disagree on a compliance-critical record?
Challenge Chase

Challenge Chase

Take this to your own organisation. Find out what your employer's actual policy is on sending data to third-party AI services — is there a data processing agreement, an approved-tools list, a named person who signs off? Then map your own work: which categories of data you handle could go to a cloud API today, and which would need the local path you just built. Most organisations have a clearer answer than people assume, and most people have never asked. The answer changes what you are allowed to build, so it is worth knowing before you propose something.

The code from this lesson

The dual-model pipeline: PII classified locally through Ollama, everything else through OpenAI, and the shared contract both providers import. You will need a key in .env and Ollama running with qwen3:1.7b pulled.

Take it if you would rather read and run the code than type it out. Typing it is still the better way to learn it — this is here so a typo cannot cost you the lesson.

Checklist

  • Ollama is installed and qwen3:1.7b is pulled (ollama list confirms)
  • classification_contract.py holds the shared prompt, labels and threshold, and both classifiers import from it
  • local_classifier.py exists with classify_record_local and classify_with_fallback_local
  • quick_test_local.py runs with no .env file present — the local path has no cloud dependency
  • The markdown code fence stripping logic is present
  • stage_classify routes pii_detected records to the local classifier and all others to OpenAI
  • pipeline_output.json includes cloud_calls, local_calls, and both model names
  • I can explain why PII records must not be sent to a cloud API in most organisational contexts

Your organisation's data processing agreement prohibits sending personally identifiable information to third-party cloud services. You have 400 records, 30 of which contain PII. What is the correct pipeline approach?


KSB evidence focus

  • K11 — Understands relevant data governance, data privacy and security issues. The dual-model routing pattern is a data governance control: PII-flagged records are handled by a local model specifically because sending them to a cloud service would violate data handling requirements. This decision is documented in the stage metadata — every pipeline run records which records went where.

  • K12 — Understands how to set up, interact with and generate APIs, databases and spreadsheets. You have integrated two separate model APIs — OpenAI's cloud API and Ollama's local REST API — behind a consistent interface. Both use the same prompt, the same validation, and produce the same output shape. This is the benefit of designing the interface before the implementation.

  • S11 — Can design, implement and test an AI/ML model or system. Running two different models in the same pipeline is a system design decision. You chose Qwen3 1.7B knowing it is less capable than GPT-4.1-mini, and validated that it is capable enough for the specific constrained classification task. That validation process is evidence of systematic thinking about model selection.


Up next: Your pipeline is making decisions — classifying records, routing them, calling local and cloud models. But when something goes wrong at 2am, can you tell exactly what happened and why? Lesson 4 adds structured logging so every decision is recorded, timestamped, and queryable.