Build a Local AI Job Copilot

Build a private AI copilot that drafts evidence-grounded job applications.

Introduction

30 Second Summary

A job description can look like a great match until one hard requirement makes the application a dead end. A polished draft can also outrun the evidence in your career history.

In this project, you will build a private AI job application copilot that compares manually collected roles with a truthful candidate profile. Its local multi-agent pipeline rejects ineligible roles before turning eligible opportunities into evidence-grounded drafts for your approval.

What You'll Build

When you run your copilot, you'll watch unsuitable jobs get rejected before a tailored application for an eligible role lands in application_queue.csv for your review.

By the end of this project, you'll have:

  • A naive matcher demonstration where the fictional Senior Python Platform Engineer role ranks above an eligible junior role. You'll see why keyword overlap needs a hard eligibility check.
  • A local eligibility and fit pipeline powered by Ollama plus IBM Granite 4 350M. It checks experience requirements before producing a structured fit assessment from your career evidence.
  • A human-controlled review queue containing a tailored summary. It also contains a cover letter plus a screening answer that must pass a truth check before manual submission.
  • Secret Mission: Add a fictional clearance-required role with strong keyword overlap. You'll prove the eligibility gate still rejects it.

Are there any prerequisites?

You'll need a Mac running macOS 14 Sonoma or newer with Python 3 plus Visual Studio Code installed.

You'll also need internet access for the initial model download plus a truthful resume or career history to replace the fictional sample data.

Before We Start

This is your moment to define the roles you want to target. You will also commit to reviewing every evidence-grounded draft before submitting it manually.

Set Up the Local AI Workspace

Your copilot will process career evidence through Ollama on your Mac. Ollama provides a local HTTP endpoint at http://localhost:11434 while the application is running.

First, you'll verify that your Mac can run Ollama. You'll then test an IBM Granite model through that local endpoint.

Finally, you'll prepare an empty Visual Studio Code workspace with Python 3 available.

In this step, get ready to:
  • Confirm your Mac is running macOS Sonoma 14 or newer.
  • Make the local IBM Granite model answer through Ollama.
  • Prepare a Visual Studio Code workspace with Python 3 available.
Verify macOS and install Ollama

Ollama requires macOS Sonoma 14 or newer. Checking first prevents an incompatible installation from blocking the local model.

  • Click the Apple menu in the upper-left corner of your screen.
  • Select About This Mac.
  • Confirm the displayed macOS major version is 14 or higher.

The window shows your installed macOS name plus its version number. A major version of 14 or higher meets the Ollama requirement.

Is Your Mac Below Version 14?

Upgrade to macOS Sonoma 14 or a newer compatible release before continuing.

  • Open the Apple menu.
  • Select System Settings.
  • Select General in the sidebar.
  • Select Software Update.
  • Follow Apple's macOS update guide to install a compatible release.

Help me check whether my Mac can run Ollama.

Choose the tab that matches your current Ollama setup.

✔️ Ollama is already installed

Your existing installation only needs to be running before the model test.

  • Press Command-Space bar to open Spotlight.
  • Type Ollama into Spotlight.
  • Press Return to launch Ollama.

ⓧ Ollama is not installed yet

The official macOS disk image installs the application locally. The application can also create the command-line link used by this project.

  • Open the official Ollama macOS download page.
  • Click Download for macOS.
  • Open ollama.dmg from your Downloads folder.
  • Drag Ollama into the Applications folder.
  • Press Command-Space bar to open Spotlight.
  • Type Ollama into Spotlight.
  • Press Return to launch Ollama.
  • Approve the request to create a CLI link in /usr/local/bin if it appears.

Why Ollama for This Project?

Ollama supports Apple M-series Macs through the CPU or GPU. It also supports Intel Macs through the CPU.

The local server lets your Python scripts call the model without adding a cloud AI service. This project uses local-only mode without account credentials.

Download and test the local model

The model supplies the local reasoning used by later agents. The exact tag is ibm/granite4:350m.

  • Press Command-Space bar to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Return to open Terminal.

Before you run this, predict whether the model will answer without a cloud account.

  • Download the local model and start its interactive session by running:
ollama run ibm/granite4:350m

What Does This Command Do?

Ollama downloads ibm/granite4:350m when the model is missing. It starts an interactive local session once the model is ready.

The model is 708MB. This is the slowest part of the setup because the download time depends on your internet connection.

You'll see download progress during the first run. The model session waits for your input after the download finishes.

  • Test the local session by entering this prompt:
Reply with LOCAL READY

What Are You Testing?

This prompt checks that Terminal can reach the running Ollama application. A response also proves the downloaded model can generate text locally.

You'll see a response from the model acknowledging your request. That response confirms the local model path is working.

  • Exit the interactive model session by entering:
/bye

Why Exit the Session?

The /bye command closes the interactive chat. Ollama remains available for local requests while its application is running.

You'll return to your normal Terminal prompt. That's your private model path ready for the copilot.

Model Session Not Starting?

  • Confirm the Ollama application is running on your Mac.
  • Reopen Terminal after approving the CLI link during installation.
  • Run the model command again after checking your internet connection.

Help me troubleshoot my local Ollama model.

Create and verify the workspace

Your model is ready to receive local requests. The copilot now needs a named workspace where every project file stays together.

  • Create the local-job-copilot folder on your Desktop by running these commands:
cd ~/Desktop
mkdir local-job-copilot
ls

What Do These Commands Do?

  • The first command moves Terminal to your Desktop.
  • The second command creates the local-job-copilot folder.
  • The final command lists your Desktop contents so you can confirm the folder exists.

You'll see local-job-copilot in the Terminal output.

Folder Missing From the Output?

Check the spelling of local-job-copilot. Make sure the folder name uses hyphens without spaces.

Help me create the project folder.

  • Press Command-Space bar to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Return to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder....
  • Select the local-job-copilot folder on your Desktop.
  • Click Open.

Visual Studio Code opens the empty local-job-copilot workspace. If a trust prompt appears, confirm it because you created this empty folder.

  • Click View in the top menu bar.
  • Click Terminal.

The integrated terminal opens at the bottom of Visual Studio Code. Its starting folder is your local-job-copilot workspace.

Before you check, predict which Python 3 version the integrated terminal will report.

  • Confirm Python 3 is available in the workspace by running:
python3 --version

What Does This Check Prove?

The command prints the Python version available to your Visual Studio Code terminal. A Python 3 result confirms the workspace can run the scripts you build next.

You'll see your installed Python version in the terminal. Your empty workspace is now ready for project files.

Python Version Not Showing?

Confirm that you entered python3 without a space. Restart Visual Studio Code if its terminal was already open before Python became available.

Help me troubleshoot Python in Visual Studio Code.

Your local model answers through Ollama. Your Visual Studio Code workspace can also run Python 3. Next up, you'll use that workspace to expose the failure hidden inside a naive job matcher.

Build the Naive Matcher

Your Visual Studio Code workspace is ready for its first Python experiment.

Keyword overlap can reward a job description for repeating familiar skills. It can ignore requirements that determine whether you are eligible.

This step makes that failure visible with fictional data. You will see an unsuitable senior role outrank an eligible junior role.

In this step, get ready to:
  • Add fictional candidate evidence with one year of experience.
  • Add two fictional roles with different experience requirements.
  • Prove that a keyword-only ranking produces a false positive.
Create the sample candidate profile

A JSON profile gives the matcher a defined set of candidate evidence. The sample makes the experiment repeatable without using your real career data.

Keep Real Applications Truthful

This profile is clearly labeled as sample evidence. Replace every sample claim with truthful career history before using the copilot for real applications.

  • Return to the Explorer sidebar in the Visual Studio Code workspace from earlier.
  • Click the New File icon at the top of the sidebar.
  • Type candidate.json as the file name.
  • Press Enter to create the file.
  • Add the fictional candidate evidence to candidate.json using the code below:
{
  "name": "Sample Candidate",
  "years_experience": 1,
  "has_clearance": false,
  "skills": [
    "Python",
    "SQL",
    "Excel",
    "Data analysis"
  ],
  "experience": [
    "Built Python scripts that automated weekly operations reports.",
    "Used SQL and Excel to clean and analyze small business datasets."
  ],
  "projects": [
    "Created a data analysis dashboard from CSV exports.",
    "Documented a repeatable reporting workflow for non-technical users."
  ],
  "constraints": {
    "locations": [
      "Remote"
    ],
    "work_authorization": "Replace this with your truthful status"
  }
}

What Does This Profile Represent?

  • The skills, experience, and projects fields contain the evidence that a job can be compared against.
  • The years_experience value records one year for this controlled test.
  • The has_clearance value records that the sample candidate has no security clearance.
  • The constraints object keeps location and work authorization facts inside the profile.
  • Save candidate.json by pressing Cmd+S.
  • Confirm that candidate.json appears in the Explorer sidebar.

Good start. Your sample evidence now has a clear boundary.

Candidate File Missing or Marked Red?

Check that the file is named candidate.json inside the local-job-copilot folder. A missing comma or quotation mark can make the editor mark the JSON as invalid.

Compare the opening and closing braces if the editor highlights the final line.

Help me check my candidate profile

✔️ Awesome, I've got everything!

Your saved candidate.json contains the complete fictional candidate profile.

ⓧ I'd like to double check the full code

Compare your saved candidate.json with this complete file.

{
  "name": "Sample Candidate",
  "years_experience": 1,
  "has_clearance": false,
  "skills": [
    "Python",
    "SQL",
    "Excel",
    "Data analysis"
  ],
  "experience": [
    "Built Python scripts that automated weekly operations reports.",
    "Used SQL and Excel to clean and analyze small business datasets."
  ],
  "projects": [
    "Created a data analysis dashboard from CSV exports.",
    "Documented a repeatable reporting workflow for non-technical users."
  ],
  "constraints": {
    "locations": [
      "Remote"
    ],
    "work_authorization": "Replace this with your truthful status"
  }
}
Add the fictional job descriptions

The experiment needs one eligible role plus one role that fails a major requirement. Repeated skill phrases make the unsuitable role look stronger to a simple matcher.

  • Click the New File icon in the Explorer sidebar.
  • Type jobs.json as the file name.
  • Press Enter to create the file.
  • Add the two fictional roles to jobs.json using the code below:
[
  {
    "company": "Sample Analytics Company (fictional)",
    "role": "Junior Data Analyst",
    "description": "Use Python, SQL, Excel, and data analysis to automate weekly reports for a small operations team.",
    "minimum_years_experience": 0,
    "requires_clearance": false,
    "must_have_skills": [
      "Python",
      "SQL",
      "Excel"
    ],
    "application_link": ""
  },
  {
    "company": "Sample Platform Company (fictional)",
    "role": "Senior Python Platform Engineer",
    "description": "Lead Python systems for a platform team. Design Python services, review Python code, improve Python automation, and tune SQL pipelines. Five or more years of experience required.",
    "minimum_years_experience": 5,
    "requires_clearance": false,
    "must_have_skills": [
      "Python",
      "SQL"
    ],
    "application_link": ""
  }
]

Why These Two Roles?

The Junior Data Analyst role matches the sample skills. Its experience requirement also fits the sample profile.

The Senior Python Platform Engineer role repeats Python several times. Its five-year requirement exceeds the candidate's one year of experience.

  • Save jobs.json by pressing Cmd+S.
  • Confirm that jobs.json appears beside candidate.json in the Explorer sidebar.

The contrast is set. Your two fictional roles now create a controlled test for the matcher.

Jobs File Missing or Invalid?

Make sure jobs.json is inside the local-job-copilot folder. Check every object for a closing brace if the editor highlights the final bracket.

Check the comma between the two job objects if only the second role is marked red.

Help me fix my jobs data

✔️ Awesome, I've got everything!

Your saved jobs.json contains both fictional roles.

ⓧ I'd like to double check the full code

Compare your saved jobs.json with this complete file.

[
  {
    "company": "Sample Analytics Company (fictional)",
    "role": "Junior Data Analyst",
    "description": "Use Python, SQL, Excel, and data analysis to automate weekly reports for a small operations team.",
    "minimum_years_experience": 0,
    "requires_clearance": false,
    "must_have_skills": [
      "Python",
      "SQL",
      "Excel"
    ],
    "application_link": ""
  },
  {
    "company": "Sample Platform Company (fictional)",
    "role": "Senior Python Platform Engineer",
    "description": "Lead Python systems for a platform team. Design Python services, review Python code, improve Python automation, and tune SQL pipelines. Five or more years of experience required.",
    "minimum_years_experience": 5,
    "requires_clearance": false,
    "must_have_skills": [
      "Python",
      "SQL"
    ],
    "application_link": ""
  }
]
Build and run the keyword matcher

The matcher uses skill phrase counts as its entire ranking signal. A description gains one point every time it repeats a skill from the candidate profile.

  • Click the New File icon in the Explorer sidebar.
  • Type naive_matcher.py as the file name.
  • Press Enter to create the file.
  • Add the data-loading and scoring helpers to naive_matcher.py using the code below:
import json


def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


def naive_score(profile, job):
    description = job["description"].lower()
    return sum(
        description.count(str(skill).lower())
        for skill in profile.get("skills", [])
    )

What Do These Helpers Do?

  • The load_json() function reads one JSON file into Python data.
  • The naive_score() function converts the job description to lowercase.
  • The score counts every occurrence of each candidate skill without checking any job requirement.
  • Save naive_matcher.py by pressing Cmd+S.
  • Confirm that the unsaved-change indicator disappears from the naive_matcher.py editor tab.

Your matcher can now load the files and calculate keyword scores.

Seeing Red Underlines in the Helpers?

Check the indentation inside load_json() and naive_score(). Python uses indentation to determine which lines belong inside each function.

Check the closing parenthesis after the score calculation if the final helper line is highlighted.

Help me fix the matcher helpers

The helpers provide the score. The main workflow now needs to rank the roles and print the result.

  • Place your cursor below the closing parenthesis in naive_score().
  • Leave two blank lines before the next function.
  • Add the ranking workflow using the code below:
def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")

    ranked = sorted(
        jobs,
        key=lambda job: naive_score(profile, job),
        reverse=True,
    )

    print("Naive keyword ranking")
    for position, job in enumerate(ranked, start=1):
        score = naive_score(profile, job)
        print("{}. {} at {}: {} keyword hits".format(
            position,
            job["role"],
            job["company"],
            score,
        ))

    print("\nProblem: this ranking ignores experience, clearance, and hard requirements.")


if __name__ == "__main__":
    main()

How Does the Ranking Work?

  • The main() function loads the candidate profile plus the two jobs.
  • The sorted() call ranks jobs from the highest keyword score to the lowest.
  • The loop prints each role with its position plus its number of keyword hits.
  • The final message names the hard requirements that the score ignores.
  • Save naive_matcher.py by pressing Cmd+S.
  • Confirm that naive_matcher.py appears beside both JSON files in the Explorer sidebar.

Main Workflow Marked Red?

Check that def main(): starts at the far left. Check that the code inside it uses four spaces of indentation.

Make sure the final if __name__ == "__main__": line also starts at the far left.

Help me fix the main workflow

✔️ Awesome, I've got everything!

Your three files are saved. The matcher is ready for the false-positive test.

ⓧ I'd like to double check the full code

Compare your saved naive_matcher.py with this complete file.

import json


def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


def naive_score(profile, job):
    description = job["description"].lower()
    return sum(
        description.count(str(skill).lower())
        for skill in profile.get("skills", [])
    )


def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")

    ranked = sorted(
        jobs,
        key=lambda job: naive_score(profile, job),
        reverse=True,
    )

    print("Naive keyword ranking")
    for position, job in enumerate(ranked, start=1):
        score = naive_score(profile, job)
        print("{}. {} at {}: {} keyword hits".format(
            position,
            job["role"],
            job["company"],
            score,
        ))

    print("\nProblem: this ranking ignores experience, clearance, and hard requirements.")


if __name__ == "__main__":
    main()

Before you run the matcher, which role do you think repeated skill phrases will push to the top?

  • Run the matcher in the integrated Terminal using this command:
python3 naive_matcher.py

What Should You See?

You should see Senior Python Platform Engineer ranked first with five keyword hits. You should see Junior Data Analyst ranked second with four keyword hits.

The senior role wins because its description repeats Python four times plus SQL once. The matcher never reads its five-year experience requirement.

You found the trap. A plausible-looking keyword score has recommended a role that the sample candidate cannot meet.

Matcher Failed or Ranked the Junior Role First?

Check that the integrated Terminal is inside the local-job-copilot folder. The script must find candidate.json plus jobs.json beside it.

Compare the senior job description with the full jobs.json reference if its keyword score differs from five.

Help me troubleshoot the naive matcher

You now have a reproducible false positive. Next, you will place inspectable job requirements ahead of model judgment.

Add Eligibility Gates and a Local Fit Agent

Your naive matcher ranked an ineligible senior role first because repeated keywords looked convincing. Now you will put hard requirements in front of the local model.

A deterministic gate checks experience, clearance, and must-have skills with normal Python rules. Only eligible jobs reach the Fit Agent through the local Ollama endpoint.

In this step, get ready to:
  • Build an eligibility gate for non-negotiable job requirements.
  • Connect schema-constrained agent roles to the local model.
  • Run the gated Fit Agent against both fictional jobs.
Build the deterministic eligibility gate

A hard requirement has a definite outcome based on verified profile data. The model does not need to interpret whether one year satisfies a five-year minimum.

  • Create a new file in the VS Code Explorer sidebar by clicking New File.
  • Name the file agents.py.
  • Add the local model settings and fit-response schema by pasting this first section into agents.py:
import json
from urllib.request import Request, urlopen


OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "ibm/granite4:350m"

FIT_SCHEMA = {
    "type": "object",
    "properties": {
        "score": {"type": "integer", "minimum": 0, "maximum": 100},
        "strengths": {"type": "array", "items": {"type": "string"}},
        "gaps": {"type": "array", "items": {"type": "string"}},
        "recommendation": {
            "type": "string",
            "enum": ["apply", "review", "skip"]
        }
    },
    "required": ["score", "strengths", "gaps", "recommendation"],
    "additionalProperties": False
}

What does this section define?

  • The imports provide JSON handling and the standard-library tools for sending a local HTTP request.
  • The model settings point every agent request at your installed Granite model through the local chat endpoint.
  • The FIT_SCHEMA dictionary requires a score, strengths, gaps, and a recommendation in every fit result.
  • Save agents.py.
  • Confirm that the VS Code Explorer sidebar now lists agents.py beside your existing files.

Is agents.py missing from the workspace?

Check that you created the file inside the open local-job-copilot workspace. A file created outside that folder does not appear beside candidate.json.

Make sure the filename ends with .py.

Help me locate my agents.py file

Each agent role needs a separate JSON Schema boundary. These boundaries keep each response focused on the fields that role must return.

  • Add the draft-response schema directly below FIT_SCHEMA by pasting this section:
DRAFT_SCHEMA = {
    "type": "object",
    "properties": {
        "resume_summary": {"type": "string"},
        "cover_letter": {"type": "string"},
        "screening_answer": {"type": "string"}
    },
    "required": ["resume_summary", "cover_letter", "screening_answer"],
    "additionalProperties": False
}

What does the draft schema control?

The schema limits a draft result to a resume summary, a cover letter, and a screening answer. It also rejects extra response fields.

  • Save agents.py.
  • Confirm that VS Code shows matching braces around the complete DRAFT_SCHEMA dictionary.

Does the draft schema show a syntax problem?

Compare every opening brace with its closing brace. Check the commas after the three property entries.

Help me fix the DRAFT_SCHEMA syntax

  • Add the truth-review schema directly below DRAFT_SCHEMA by pasting this section:
GUARD_SCHEMA = {
    "type": "object",
    "properties": {
        "approved": {"type": "boolean"},
        "unsupported_claims": {
            "type": "array",
            "items": {"type": "string"}
        },
        "review_notes": {
            "type": "array",
            "items": {"type": "string"}
        }
    },
    "required": ["approved", "unsupported_claims", "review_notes"],
    "additionalProperties": False
}

What does the guard schema control?

The schema requires a clear approval decision. It also preserves unsupported claims and review notes as separate lists for human inspection.

  • Save agents.py.
  • Confirm that GUARD_SCHEMA contains the three required field names shown in its required list.

Are the guard fields incomplete?

Check that approved, unsupported_claims, and review_notes appear in both the properties and required sections.

Help me compare the GUARD_SCHEMA fields

The eligibility gate makes inspectable decisions before any model request. Its first check compares the candidate's verified experience with the job minimum.

  • Start eligibility_gate directly below GUARD_SCHEMA by pasting this first part:
def eligibility_gate(profile, job):
    reasons = []

    candidate_years = profile.get("years_experience", 0)
    required_years = job.get("minimum_years_experience", 0)
    if candidate_years < required_years:
        reasons.append(
            "Needs {} years; candidate profile has {}.".format(
                required_years,
                candidate_years,
            )
        )

How does the experience check work?

  • The reasons list collects every hard requirement that the profile fails.
  • The gate reads the candidate and job values with safe numeric defaults.
  • A failed comparison adds a specific reason that shows both experience values.

Clearance and must-have skills are also hard requirements. The function collects all failures so the learner can inspect the complete rejection decision.

  • Complete eligibility_gate by pasting this continuation directly below the experience check:
    if job.get("requires_clearance", False) and not profile.get("has_clearance", False):
        reasons.append("Requires security clearance not present in the profile.")

    candidate_skills = {
        str(skill).lower() for skill in profile.get("skills", [])
    }
    missing_skills = [
        skill
        for skill in job.get("must_have_skills", [])
        if str(skill).lower() not in candidate_skills
    ]
    if missing_skills:
        reasons.append("Missing must-have skills: {}.".format(
            ", ".join(missing_skills)
        ))

    return {
        "eligible": not reasons,
        "reasons": reasons,
    }

How does the completed gate decide?

  • The clearance check rejects a requirement that the candidate profile does not support.
  • The skill check normalizes capitalization before comparing must-have skills.
  • The returned eligible value becomes true only when the reasons list is empty.
  • Save agents.py.
  • Confirm that return aligns with the three requirement checks inside eligibility_gate.

Why use a deterministic gate first?

Normal Python comparisons give the same answer for the same verified evidence. This keeps non-negotiable requirements inspectable.

A prompt-based eligibility decision could vary between responses. The model now handles nuanced fit only after the hard gate passes.

Does the gate indentation look uneven?

Keep every requirement check indented four spaces inside eligibility_gate. Keep the code inside each condition indented one additional level.

Help me fix eligibility_gate indentation

Connect the local agent roles

Eligible jobs now need a structured fit assessment from the local model. A shared request helper sends each role's prompt and schema through the same local endpoint.

  • Start call_ollama directly below eligibility_gate by pasting this request-building section:
def call_ollama(system_prompt, user_prompt, schema):
    payload = {
        "model": MODEL,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        "stream": False,
        "format": schema,
        "options": {"temperature": 0}
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )

How is the local request prepared?

  • The payload selects the local model and includes separate system and user messages.
  • The supplied schema becomes the response format for the current agent role.
  • A temperature of 0 reduces response variation for this structured workflow.
  • The request encodes the payload as JSON for the local chat endpoint.

The helper also needs clear failure handling. This turns connection problems and malformed model responses into useful Python exceptions.

  • Complete call_ollama by pasting this continuation directly below the request:
    try:
        with urlopen(request, timeout=120) as response:
            envelope = json.loads(response.read().decode("utf-8"))
    except OSError as error:
        raise RuntimeError(
            "Could not reach local Ollama. Start Ollama and confirm the model is installed."
        ) from error

    try:
        return json.loads(envelope["message"]["content"])
    except (KeyError, TypeError, ValueError) as error:
        raise RuntimeError("Ollama returned an unexpected response.") from error

How is the local response handled?

  • The request waits up to 120 seconds for the local response.
  • The first error path reports that the local model service could not be reached.
  • The second error path catches a response that does not contain valid structured JSON in the expected location.
  • Save agents.py.
  • Confirm that both try blocks remain inside call_ollama.

Is the request helper nested incorrectly?

Keep the payload, request, and both error-handling sections indented inside call_ollama. The two try blocks align with each other.

Help me check call_ollama

The Fit Agent receives only the supplied profile and job. Its prompt forbids invented evidence while its schema fixes the response shape.

  • Add fit_agent directly below call_ollama by pasting this function:
def fit_agent(profile, job):
    system_prompt = (
        "You are Fit Agent. Compare the job with only the supplied candidate "
        "evidence. Score fit from 0 to 100, list strengths and gaps, and choose "
        "apply, review, or skip. Never invent experience, credentials, or metrics."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, FIT_SCHEMA)

What does the Fit Agent receive?

The system prompt defines the role's evidence boundary. The user prompt contains the complete candidate profile and one job as formatted JSON.

The final call pairs those prompts with FIT_SCHEMA so the result can be read predictably by Python.

  • Save agents.py.
  • Confirm that fit_agent ends by passing FIT_SCHEMA to call_ollama.

Does the Fit Agent prompt look broken?

Keep each adjacent prompt fragment inside the same parentheses. Check that the newline escape sequences remain inside the user_prompt string.

Help me fix the fit_agent prompt

The shared module also defines the bounded drafting and truth-review roles used by the complete pipeline. They reuse the same local request helper with their own prompts and schemas.

  • Add draft_agent directly below fit_agent by pasting this function:
def draft_agent(profile, job, fit_result):
    system_prompt = (
        "You are Draft Agent. Create a concise resume summary, a short cover "
        "letter, and an answer to 'Why are you interested in this role?'. Use "
        "only facts explicitly present in the candidate profile. Do not invent "
        "numbers, employers, credentials, tools, or achievements."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}\n\nFit result:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
        json.dumps(fit_result, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, DRAFT_SCHEMA)

How is drafting bounded?

The draft prompt permits only facts found in the candidate profile. The Fit Agent result supplies relevant strengths and gaps without becoming new candidate evidence.

The function requests the exact fields defined by DRAFT_SCHEMA.

  • Save agents.py.
  • Confirm that draft_agent passes the profile, job, and fit result into its user prompt.

Is the draft prompt missing a value?

Check that the format call contains three JSON values. Keep them in profile, job, and fit-result order.

Help me check draft_agent

  • Add truth_guard directly below draft_agent by pasting this function:
def truth_guard(profile, job, draft):
    system_prompt = (
        "You are Truth Guard. Compare every concrete claim in the draft with "
        "the candidate profile. Approve only when each claim is directly "
        "supported. List unsupported claims exactly and provide review notes."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}\n\nDraft:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
        json.dumps(draft, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, GUARD_SCHEMA)

How does the Truth Guard stay focused?

The Truth Guard compares draft claims with the candidate profile. Its response separates approval, unsupported claims, and review notes through GUARD_SCHEMA.

  • Save agents.py.
  • Confirm that truth_guard is the final function in agents.py.

Is the final function outside agents.py?

Check the VS Code tab name before saving. The truth_guard function belongs at the bottom of agents.py.

Help me check the agents.py function order

✔️ Awesome, I've got everything!

Your agents.py file now contains the deterministic gate, the shared local request helper, and the three bounded agent roles.

ⓧ I'd like to double check the full code

Compare your saved agents.py with this complete reference:

import json
from urllib.request import Request, urlopen


OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "ibm/granite4:350m"

FIT_SCHEMA = {
    "type": "object",
    "properties": {
        "score": {"type": "integer", "minimum": 0, "maximum": 100},
        "strengths": {"type": "array", "items": {"type": "string"}},
        "gaps": {"type": "array", "items": {"type": "string"}},
        "recommendation": {
            "type": "string",
            "enum": ["apply", "review", "skip"]
        }
    },
    "required": ["score", "strengths", "gaps", "recommendation"],
    "additionalProperties": False
}

DRAFT_SCHEMA = {
    "type": "object",
    "properties": {
        "resume_summary": {"type": "string"},
        "cover_letter": {"type": "string"},
        "screening_answer": {"type": "string"}
    },
    "required": ["resume_summary", "cover_letter", "screening_answer"],
    "additionalProperties": False
}

GUARD_SCHEMA = {
    "type": "object",
    "properties": {
        "approved": {"type": "boolean"},
        "unsupported_claims": {
            "type": "array",
            "items": {"type": "string"}
        },
        "review_notes": {
            "type": "array",
            "items": {"type": "string"}
        }
    },
    "required": ["approved", "unsupported_claims", "review_notes"],
    "additionalProperties": False
}


def eligibility_gate(profile, job):
    reasons = []

    candidate_years = profile.get("years_experience", 0)
    required_years = job.get("minimum_years_experience", 0)
    if candidate_years < required_years:
        reasons.append(
            "Needs {} years; candidate profile has {}.".format(
                required_years,
                candidate_years,
            )
        )

    if job.get("requires_clearance", False) and not profile.get("has_clearance", False):
        reasons.append("Requires security clearance not present in the profile.")

    candidate_skills = {
        str(skill).lower() for skill in profile.get("skills", [])
    }
    missing_skills = [
        skill
        for skill in job.get("must_have_skills", [])
        if str(skill).lower() not in candidate_skills
    ]
    if missing_skills:
        reasons.append("Missing must-have skills: {}.".format(
            ", ".join(missing_skills)
        ))

    return {
        "eligible": not reasons,
        "reasons": reasons,
    }


def call_ollama(system_prompt, user_prompt, schema):
    payload = {
        "model": MODEL,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        "stream": False,
        "format": schema,
        "options": {"temperature": 0}
    }

    request = Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )

    try:
        with urlopen(request, timeout=120) as response:
            envelope = json.loads(response.read().decode("utf-8"))
    except OSError as error:
        raise RuntimeError(
            "Could not reach local Ollama. Start Ollama and confirm the model is installed."
        ) from error

    try:
        return json.loads(envelope["message"]["content"])
    except (KeyError, TypeError, ValueError) as error:
        raise RuntimeError("Ollama returned an unexpected response.") from error


def fit_agent(profile, job):
    system_prompt = (
        "You are Fit Agent. Compare the job with only the supplied candidate "
        "evidence. Score fit from 0 to 100, list strengths and gaps, and choose "
        "apply, review, or skip. Never invent experience, credentials, or metrics."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, FIT_SCHEMA)


def draft_agent(profile, job, fit_result):
    system_prompt = (
        "You are Draft Agent. Create a concise resume summary, a short cover "
        "letter, and an answer to 'Why are you interested in this role?'. Use "
        "only facts explicitly present in the candidate profile. Do not invent "
        "numbers, employers, credentials, tools, or achievements."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}\n\nFit result:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
        json.dumps(fit_result, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, DRAFT_SCHEMA)


def truth_guard(profile, job, draft):
    system_prompt = (
        "You are Truth Guard. Compare every concrete claim in the draft with "
        "the candidate profile. Approve only when each claim is directly "
        "supported. List unsupported claims exactly and provide review notes."
    )
    user_prompt = "Candidate profile:\n{}\n\nJob:\n{}\n\nDraft:\n{}".format(
        json.dumps(profile, indent=2),
        json.dumps(job, indent=2),
        json.dumps(draft, indent=2),
    )
    return call_ollama(system_prompt, user_prompt, GUARD_SCHEMA)
Run the gated Fit Agent

The runner applies eligibility_gate before fit_agent for every job. An ineligible role prints its reasons without reaching the model.

  • Create another file in the VS Code Explorer sidebar by clicking New File.
  • Name the file fit_agent.py.
  • Add the imports and JSON loader by pasting this first section into fit_agent.py:
import json

from agents import eligibility_gate, fit_agent


def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)

What does the runner import?

  • The runner imports the deterministic gate and local Fit Agent from agents.py.
  • Its load_json helper reads the existing candidate and job files as Python data.
  • Save fit_agent.py.
  • Confirm that the imported function names match eligibility_gate and fit_agent in agents.py.

Does VS Code underline the imports?

Check that agents.py and fit_agent.py are in the same local-job-copilot folder. Compare both imported names with their function definitions.

Help me fix the local imports

The main loop exposes the gate decision and the structured fit result in the terminal. This makes the boundary between deterministic rejection and model reasoning visible.

  • Complete fit_agent.py by pasting this section directly below load_json:
def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")

    print("Eligibility gate and local Fit Agent")
    for job in jobs:
        gate = eligibility_gate(profile, job)

        if not gate["eligible"]:
            print("SKIP: {} at {}".format(job["role"], job["company"]))
            for reason in gate["reasons"]:
                print("  - {}".format(reason))
            continue

        result = fit_agent(profile, job)
        print("FIT: {} at {}: {} / 100 ({})".format(
            job["role"],
            job["company"],
            result["score"],
            result["recommendation"],
        ))
        print("  Strengths: {}".format("; ".join(result["strengths"])))
        print("  Gaps: {}".format("; ".join(result["gaps"])))


if __name__ == "__main__":
    main()

How does the runner protect model time?

  • Each job enters eligibility_gate first.
  • A failed job prints SKIP with every deterministic reason.
  • The continue statement prevents an ineligible job from reaching fit_agent.
  • An eligible job prints its structured score, recommendation, strengths, and gaps.
  • Save fit_agent.py.
  • Confirm that the final main() call sits outside the function definition.

Does the runner structure look wrong?

Keep the job loop inside main.

Keep the final condition and main() call aligned at the far left.

Help me fix fit_agent.py

✔️ Awesome, I've got everything!

Your fit_agent.py runner now gates each job before requesting a structured local fit assessment.

ⓧ I'd like to double check the full code

Compare your saved fit_agent.py with this complete reference:

import json

from agents import eligibility_gate, fit_agent


def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")

    print("Eligibility gate and local Fit Agent")
    for job in jobs:
        gate = eligibility_gate(profile, job)

        if not gate["eligible"]:
            print("SKIP: {} at {}".format(job["role"], job["company"]))
            for reason in gate["reasons"]:
                print("  - {}".format(reason))
            continue

        result = fit_agent(profile, job)
        print("FIT: {} at {}: {} / 100 ({})".format(
            job["role"],
            job["company"],
            result["score"],
            result["recommendation"],
        ))
        print("  Strengths: {}".format("; ".join(result["strengths"])))
        print("  Gaps: {}".format("; ".join(result["gaps"])))


if __name__ == "__main__":
    main()

Before you run the script, which role do you expect the deterministic gate to reject before the local model receives it?

  • Run the gated Fit Agent in the integrated Terminal with this command:
python3 fit_agent.py

What should you see?

The first local response can take longer while the model loads. A quiet pause during that first assessment is normal.

You should see a structured FIT result for Junior Data Analyst. The exact score, recommendation, strengths, and gaps can vary.

You should also see SKIP for Senior Python Platform Engineer. Its reason states that the job needs five years while the candidate profile has one.

That is the safety boundary working. The ineligible senior role is rejected by inspectable Python logic before model reasoning begins.

Did the gated run fail?

If Python cannot find a project file, confirm that the integrated Terminal is still inside the local-job-copilot workspace.

If the local request fails, return to the Ollama application from earlier. Confirm that it is still running with the Granite model installed.

Help me troubleshoot the gated Fit Agent

Strong progress. Your copilot now rejects hard mismatches before spending local model time on nuanced fit analysis. Next, you will turn eligible results into evidence-grounded drafts with a human review queue.

Build the Draft, Truth Guard, and Review Queue

Your eligibility gate now stops the senior role before Ollama spends time on it. The eligible junior role still ends at a fit score.

Unchecked generation can invent qualifications. You will connect evidence-grounded drafting to a Truth Guard before every eligible role enters a human-controlled CSV queue.

In this step, get ready to:
  • Configure the CSV review queue.
  • Assemble the complete application pipeline.
  • Verify every eligible draft lands in the human review queue.
Create the review queue foundation

The application_pipeline.py file coordinates the agents that already exist in agents.py. Its first responsibility is defining a consistent queue structure for every eligible role.

  • Select the local-job-copilot folder in Visual Studio Code's left sidebar.
  • Click the file-with-plus icon at the top of the sidebar.
  • Type application_pipeline.py into the filename field.
  • Press Enter.
  • Add the queue configuration to application_pipeline.py by pasting this code:
import csv
import json

from agents import draft_agent, eligibility_gate, fit_agent, truth_guard


QUEUE_PATH = "application_queue.csv"
QUEUE_FIELDS = [
    "company",
    "role",
    "application_link",
    "fit_score",
    "recommendation",
    "guard_status",
    "unsupported_claims",
    "resume_summary",
    "cover_letter",
    "screening_answer",
    "status",
]

What does this configuration do?

  • The imports connect the pipeline to the four agent functions already defined in agents.py.
  • QUEUE_PATH keeps the review queue at application_queue.csv inside local-job-copilot.
  • QUEUE_FIELDS fixes the column order for fit results, generated drafts, guard findings, and human-review status.
  • Save application_pipeline.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm application_pipeline.py appears inside local-job-copilot in the left sidebar.

File not in the sidebar?

Check that you selected the local-job-copilot folder before creating the file. Move the file into that folder if it appears beside the workspace files.

Help me place application_pipeline.py in the correct folder.

The queue needs one helper to read source data. A second helper writes each prepared application with the same columns.

  • Add the file helpers below QUEUE_FIELDS in application_pipeline.py by pasting this code:
def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


def save_queue(rows):
    with open(QUEUE_PATH, "w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=QUEUE_FIELDS)
        writer.writeheader()
        writer.writerows(rows)

How do the file helpers work?

  • load_json() reads the candidate profile or job list from a supplied path.
  • save_queue() creates application_queue.csv with the exact fields defined in QUEUE_FIELDS.
  • writeheader() writes the column names before writerows(rows) adds the prepared applications.
  • Save application_pipeline.py.
  • Confirm the final line in the file is writer.writerows(rows).

Seeing indentation warnings?

Keep the three writer lines inside the with open block. Each line should have eight leading spaces.

Help me fix the file helper indentation.

Assemble the human review pipeline

A safe pipeline preserves the ordering you proved earlier. Every job reaches eligibility_gate() before any model-backed agent can process it.

  • Add the first runnable version of main() below save_queue() by pasting this code:
def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")
    queue_rows = []

    print("Local application pipeline")
    for job in jobs:
        gate = eligibility_gate(profile, job)

        if not gate["eligible"]:
            print("SKIP: {} at {}".format(job["role"], job["company"]))
            for reason in gate["reasons"]:
                print("  - {}".format(reason))
            continue

    save_queue(queue_rows)
    print("\nSaved {} item(s) to {}.".format(len(queue_rows), QUEUE_PATH))
    print("Review every claim and submit manually on the employer's site.")


if __name__ == "__main__":
    main()

What does this first pipeline slice prove?

This first runnable slice focuses on deterministic gate behavior. It writes the CSV header after processing every job.

The senior role reaches continue after printing its rejection reason. The eligible role leaves the gate without adding a queue row in this first slice.

  • Save application_pipeline.py.

Before you run this first slice, which role do you expect the eligibility gate to skip?

  • Test the gate and queue writer by running this command in the integrated terminal:
python3 application_pipeline.py

What should you see?

The senior role prints SKIP with the five-year requirement. The script reports Saved 0 item(s) to application_queue.csv.

That empty first queue proves the hard gate still runs before the model workflow. It also proves the CSV writer creates a valid queue file.

Pipeline stopped before saving?

Confirm the integrated terminal is still inside local-job-copilot. Check that application_pipeline.py lines up with the code above.

Help me fix the first application pipeline run.

The gate-only run confirms the boundary for ineligible jobs. Eligible jobs can now move through fit analysis, drafting, and claim review before entering the queue.

  • Locate continue inside the ineligible-job block in application_pipeline.py.
  • Place your cursor on the blank line after the ineligible-job block.
  • Insert the model-backed stages inside for job in jobs: by pasting this code:
        fit_result = fit_agent(profile, job)
        draft = draft_agent(profile, job, fit_result)
        guard = truth_guard(profile, job, draft)

        guard_status = "approved" if guard["approved"] else "blocked"
        status = (
            "human_review_required"
            if guard["approved"]
            else "revise_before_review"
        )

        queue_rows.append({
            "company": job["company"],
            "role": job["role"],
            "application_link": job.get("application_link", ""),
            "fit_score": fit_result["score"],
            "recommendation": fit_result["recommendation"],
            "guard_status": guard_status,
            "unsupported_claims": "; ".join(guard["unsupported_claims"]),
            "resume_summary": draft["resume_summary"],
            "cover_letter": draft["cover_letter"],
            "screening_answer": draft["screening_answer"],
            "status": status,
        })

        print("QUEUE: {} at {}: {}".format(
            job["role"],
            job["company"],
            status,
        ))

How does the complete pipeline stay bounded?

  • fit_agent() produces the structured score for an eligible role.
  • draft_agent() creates the resume summary, cover letter, and screening answer from candidate evidence.
  • truth_guard() reviews each generated claim against the profile.
  • guard_status records approved or blocked from the guard result.
  • status sends every draft to human_review_required or revise_before_review.

The Truth Guard provides a separate review prompt. Human review remains mandatory because the same local model performs drafting and checking.

  • Save application_pipeline.py.

✔️ Awesome, I've got everything!

Your complete pipeline is saved. The eligible path now ends in a human review queue.

ⓧ I'd like to double check the full code

import csv
import json

from agents import draft_agent, eligibility_gate, fit_agent, truth_guard


QUEUE_PATH = "application_queue.csv"
QUEUE_FIELDS = [
    "company",
    "role",
    "application_link",
    "fit_score",
    "recommendation",
    "guard_status",
    "unsupported_claims",
    "resume_summary",
    "cover_letter",
    "screening_answer",
    "status",
]


def load_json(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


def save_queue(rows):
    with open(QUEUE_PATH, "w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=QUEUE_FIELDS)
        writer.writeheader()
        writer.writerows(rows)


def main():
    profile = load_json("candidate.json")
    jobs = load_json("jobs.json")
    queue_rows = []

    print("Local application pipeline")
    for job in jobs:
        gate = eligibility_gate(profile, job)

        if not gate["eligible"]:
            print("SKIP: {} at {}".format(job["role"], job["company"]))
            for reason in gate["reasons"]:
                print("  - {}".format(reason))
            continue

        fit_result = fit_agent(profile, job)
        draft = draft_agent(profile, job, fit_result)
        guard = truth_guard(profile, job, draft)

        guard_status = "approved" if guard["approved"] else "blocked"
        status = (
            "human_review_required"
            if guard["approved"]
            else "revise_before_review"
        )

        queue_rows.append({
            "company": job["company"],
            "role": job["role"],
            "application_link": job.get("application_link", ""),
            "fit_score": fit_result["score"],
            "recommendation": fit_result["recommendation"],
            "guard_status": guard_status,
            "unsupported_claims": "; ".join(guard["unsupported_claims"]),
            "resume_summary": draft["resume_summary"],
            "cover_letter": draft["cover_letter"],
            "screening_answer": draft["screening_answer"],
            "status": status,
        })

        print("QUEUE: {} at {}: {}".format(
            job["role"],
            job["company"],
            status,
        ))

    save_queue(queue_rows)
    print("\nSaved {} item(s) to {}.".format(len(queue_rows), QUEUE_PATH))
    print("Review every claim and submit manually on the employer's site.")


if __name__ == "__main__":
    main()
Run and inspect the review queue

The complete run makes three local model calls for the eligible role. Expect it to take longer than the fit-only script because each response is generated on your Mac.

Before you run the complete pipeline, how many roles do you expect to reach the queue?

  • Run the complete application workflow from the integrated terminal with this command:
python3 application_pipeline.py

What should you see?

  • The Senior Python Platform Engineer role prints SKIP before any drafting call.
  • The Junior Data Analyst role prints QUEUE with a human-review status.
  • The final message reports Saved 1 item(s) to application_queue.csv.

The exact fit score and draft text depend on the local model response. The status is human_review_required when the guard approves or revise_before_review when it finds unsupported claims.

Pipeline stopped before saving?

Switch back to the Ollama application from earlier to confirm it is still running. Confirm the ibm/granite4:350m model remains installed.

If the terminal mentions an unexpected response, rerun the command once. A fresh local generation can resolve a malformed structured response.

Help me debug the complete local application pipeline.

  • Select application_queue.csv in Visual Studio Code's left sidebar.
  • Confirm the header starts with company,role,application_link,fit_score,recommendation,guard_status.
  • Locate the single row for Junior Data Analyst at Sample Analytics Company (fictional).
  • Check that guard_status contains approved or blocked.
  • Check that status contains human_review_required or revise_before_review.
  • Compare every generated claim with candidate.json.
  • Submit any approved draft manually on the employer's site.

You have closed the loop from eligibility to a reviewable draft. Every prepared application now stays under your control.

Queue missing the junior role?

Confirm jobs.json still contains the eligible junior role. Check that the model-backed block is indented inside for job in jobs:.

Rerun the pipeline after saving every Python file. The command overwrites application_queue.csv with the latest result.

Help me restore the missing queue row.

Secret mission

Red-Team the Eligibility Gate

A job can repeat every skill in your profile while hiding a disqualifying requirement. Add a clearance-required role to prove your deterministic gate protects the review queue from a misleading keyword score.

Clean Up Your Resources

Clean Up Your Resources

All project resources live on your Mac, so there are no ongoing cloud costs. Choose whether to keep them running, pause your local model, or delete the project resources entirely.

Resources you used:

  • The IBM Granite 4 350M model stored locally by Ollama under the tag ibm/granite4:350m.
  • The local-job-copilot workspace containing your local pipeline files.
  • The generated application_queue.csv review queue inside the workspace.

Keep everything running

No action is needed. Choose this option if you want the copilot ready for another batch of manually collected jobs.

  • Continue running Ollama when you want the local pipeline available.
  • Keep ibm/granite4:350m installed for future local model calls.
  • Keep the local-job-copilot workspace with its scripts and review queue.

Pause - I'll come back to this later

Stop the local model process to free up system resources. Your model and workspace remain on disk for the next session.

  • Quit Ollama to stop the local process.
  • Leave ibm/granite4:350m installed on your Mac.
  • Leave the local-job-copilot workspace in place.

Delete - I don't want to use this again

Removing the workspace deletes your profile edits, job data, scripts, and review queue. The model can be downloaded again if you rebuild the project.

  • Remove the local ibm/granite4:350m model by running this command:
ollama rm ibm/granite4:350m

What does this command remove?

This removes the ibm/granite4:350m model from local storage. Ollama remains installed on your Mac.

Model Removal Not Working?

  • Return to the Ollama application from earlier if it has stopped.
  • Run the removal command again.

Help me troubleshoot why Ollama cannot remove my local model.

  • Quit Ollama from the macOS menu bar.
  • Locate the local-job-copilot workspace from earlier in Finder.
  • Move the local-job-copilot folder to the trash.
  • Empty the trash to remove the workspace files and application_queue.csv.

Nice Work!

Nice Work!

You did it! Your private local copilot now prepares evidence-grounded job applications under human control.

You've learned how to:

  • Expose a realistic false positive when a naive keyword matcher ranks an ineligible senior role above an eligible junior role.
  • Build a multi-agent pipeline that applies deterministic eligibility gates before requesting schema-constrained fit analysis from IBM Granite 4 350M through Ollama's local HTTP endpoint.
  • Create evidence-grounded application drafts that pass through a Truth Guard before entering application_queue.csv for human review.
  • Secret Mission: Red-team the eligibility gate with the high-keyword AI Automation Engineer role. Its clearance requirement still triggers a rejection before any model call.

Ready to quiz yourself?