Build a Privacy-Aware Skill Matcher
Build a local app that explains resume-to-job skills with private AI coaching.
Introduction
30 Second Summary
A job description can use one phrase while your resume uses another. A quick keyword check can make the same skill look missing.
In this project, you will build a local Streamlit app for explainable resume-to-job matching. Optional OpenRouter coaching receives only non-personal scores and skill names.
What You'll Build
Click Analyze Match to turn resume text into a visible report of strengths, missing skills, supporting evidence, and privacy-aware next steps.
By the end of this project, you'll have:
- A private-first input screen where you can upload a text-based PDF resume or use synthetic text. The uploaded file stays in memory.
- An explainable local report built with TF-IDF and cosine similarity. It displays lexical similarity, alias-aware similarity, skill coverage, matched skills, missing skills, and supporting resume evidence. You can watch ML miss machine learning before alias normalization closes the vocabulary gap.
- A privacy-aware coaching panel that requests three improvement suggestions from OpenRouter. Only local scores and skill names leave your computer.
- A Secret Mission: Build a robustness test that compares word matching, alias-aware matching, and character n-gram matching on abbreviations and typos.
Are there any prerequisites?
You need a Windows computer with Python 3.11 through 3.14. You also need an OpenRouter API key.
You should feel comfortable writing simple Python scripts. Familiarity with pandas and basic machine learning concepts helps you understand the scoring logic.
Before We Start
This step is your moment to commit to a job application matcher that compares resume text with a job description through transparent local NLP. Raw resume and job-description text stays local because the optional OpenRouter request receives only non-personal scores and skill names.
Set Up the Windows Project
A privacy-aware app still needs a dependable local foundation. A project-specific virtual environment keeps this app's packages separate from the rest of your Python setup.
This step checks that your interpreter supports every pinned package. It then prepares a reproducible workspace in Windows PowerShell and Visual Studio Code.
In this step, get ready to:
- Confirm a supported Python version.
- Prepare the project in Visual Studio Code.
- Run the starter Streamlit app inside an isolated environment.
Confirm Python and open the project
The pinned packages require Python 3.11 through 3.14. Checking the interpreter first prevents compatibility problems during installation.
- Press the Windows key to open Windows search.
- Type Windows PowerShell into the search bar.
- Press Enter to start Windows PowerShell.
- Check your installed Python version by running:
python --version
What does this command do?
The --version flag prints the version used by the python command. Your result determines which path to follow below.
✔️ I see version 3.11 through 3.14
Your interpreter supports every pinned package in this project. That compatibility check removes the first major source of installation problems.
ⓧ I see an older version
Your current interpreter falls below the supported range. Install Python 3.14.7 before creating the environment.
- Visit the official Python downloads page for Windows.
- Select Download Windows installer (64-bit) for Python 3.14.7.
- Complete the Python installer.
- Close the existing Windows PowerShell window.
- Start a fresh Windows PowerShell session through Windows search.
- Confirm the updated version by running:
python --version
Why check again?
A fresh PowerShell session loads the updated Python command. You should now see Python 3.14.7 in the output.
ⓧ Command not found
PowerShell cannot find a local Python installation. Install the current stable Python 3.14.7 Windows release.
- Visit the official Python downloads page for Windows.
- Select Download Windows installer (64-bit) for Python 3.14.7.
- Complete the Python installer.
- Close the existing Windows PowerShell window.
- Start a fresh Windows PowerShell session through Windows search.
- Confirm the installation by running:
python --version
What should you see?
PowerShell should print Python 3.14.7. This confirms that the interpreter is available from your terminal.
Still seeing the wrong Python result?
Close every PowerShell window after installing Python. Start a fresh session before repeating the version check.
Use help me fix my Windows Python version if the result still falls outside the supported range.
Visual Studio Code gives you one workspace for the project files and terminal. Choose the path that matches your computer.
✔️ Visual Studio Code is installed
Your editor is ready for the project folder. Continue with the shared folder instructions below.
ⓧ I need to install it
The recommended User setup installs Visual Studio Code for your Windows account. It also makes the code command available from PowerShell.
- Visit the official Visual Studio Code Windows setup guide.
- Download the recommended User setup.
- Complete the Visual Studio Code installation.
- Close the existing Windows PowerShell window.
- Start a fresh Windows PowerShell session through Windows search.
- Use the Windows file browser to create job-skill-matcher on your Desktop.
- Enter the job-skill-matcher folder.
- Use the folder's terminal shortcut to start Windows PowerShell in this folder.
- Open the current folder in Visual Studio Code by running:
code .
What does this command do?
The code . command opens the current PowerShell folder in Visual Studio Code. The dot represents the folder you are currently inside.
You should see job-skill-matcher named in the editor's file sidebar. This confirms that every file you create next lands in the correct folder.
Project folder not opening?
Confirm that PowerShell is inside job-skill-matcher before running the command. Restart PowerShell if it cannot find code after the editor installation.
Use help me open my project folder if the command still fails.
Build the isolated environment
Pinned dependencies make the project reproducible across supported Python versions. The requirements.txt file records the exact package set used by the finished app.
- Create requirements.txt inside job-skill-matcher with the file contents below:
streamlit==1.64.0
pandas==3.0.5
scikit-learn==1.9.1
pypdf==6.18.1
requests==2.34.2
What do these packages provide?
- Streamlit provides the local web interface.
- pandas creates the skill report tables.
- scikit-learn provides the text vectorizer and similarity calculation.
- pypdf extracts text from PDF uploads.
- Requests sends the optional API request later in the project.
- Save requirements.txt.
- Confirm that requirements.txt appears directly inside job-skill-matcher.
- Create .gitignore inside job-skill-matcher with the file contents below:
.venv/
.streamlit/secrets.toml
__pycache__/
What does this file protect?
The .gitignore file tells Git to exclude the virtual environment and local secret file. It also excludes Python's generated cache folder.
- Save .gitignore.
- Confirm that .gitignore appears beside requirements.txt.
- Create the isolated .venv environment by running:
python -m venv .venv
What does this command do?
Python creates an isolated interpreter inside the .venv folder. Packages installed there remain scoped to this project.
- Confirm that .venv now appears inside the editor's file sidebar.
- Activate the virtual environment by running:
.venv\Scripts\Activate.ps1
What does activation change?
Activation makes this PowerShell session use the interpreter inside .venv. The environment name at the start of the prompt confirms which interpreter receives your packages.
✔️ I see (.venv)
Your PowerShell prompt now begins with (.venv). The isolated environment is active.
ⓧ PowerShell blocks the script
PowerShell can block local activation scripts under its current execution policy. Apply the documented current-user setting before trying activation again.
- Allow locally created scripts for your Windows account by running:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
What does this command change?
The command changes the execution policy for your current Windows user. It allows the virtual environment's local activation script to run.
- Activate the virtual environment again by running:
.venv\Scripts\Activate.ps1
What should you see?
Your prompt should now begin with (.venv). This confirms that PowerShell is using the project environment.
The first package installation may take a few minutes while Python downloads the pinned dependencies. A steady stream of package messages means the installation is progressing.
- Install every pinned package into the active environment by running:
python -m pip install -r requirements.txt
What does this command do?
The active Python interpreter runs pip against requirements.txt. Each package is installed into .venv at its recorded version.
You should see the installation finish without an error. Your isolated environment now contains the complete dependency set.
Package installation failing?
Confirm that the prompt begins with (.venv). Check that every line in requirements.txt matches the pinned list above.
Use help me diagnose my package installation for help with the output.
Create the starter files and launch Streamlit
Streamlit reads project-specific secrets from a .streamlit folder. This setup uses a safe placeholder while the real OpenRouter key stays out of the project code.
- Create a .streamlit folder inside job-skill-matcher using the editor's file controls.
You should see .streamlit nested inside the project folder. This gives the app a dedicated location for local configuration.
- Create .streamlit/secrets.toml.example with the file contents below:
OPENROUTER_API_KEY = "your-api-key-here"
Why create an example file?
The example records the required key name without containing a live credential. It gives the local secret file a safe template.
- Save .streamlit/secrets.toml.example.
- Confirm that the example file appears inside .streamlit.
- Create .streamlit/secrets.toml by copying .streamlit/secrets.toml.example in the editor's file sidebar.
- Keep your-api-key-here as the value during this setup step.
You should now see both secret files inside .streamlit. The local copy is already covered by .gitignore.
- Create matcher.py directly inside job-skill-matcher with the file contents below:
import re
from pypdf import PdfReader
What does this starter file prepare?
The re module supports phrase matching later in the project. PdfReader prepares the matcher to read text-based PDF uploads.
- Save matcher.py.
- Confirm that matcher.py appears directly inside job-skill-matcher.
- Create app.py directly inside job-skill-matcher with the file contents below:
import streamlit as st
st.title("Job Application Skill Matcher")
What does this starter app do?
The import makes Streamlit available through st. The title call renders the app name at the top of the browser page.
- Save app.py.
- Confirm that app.py appears beside matcher.py.
✔️ Awesome, I've got everything!
Great. Double-check that every file is saved before launching the app.
ⓧ I'd like to double check the full code
Compare each project file with the references below. Your local .streamlit/secrets.toml should contain the same placeholder as the example file.
streamlit==1.64.0
pandas==3.0.5
scikit-learn==1.9.1
pypdf==6.18.1
requests==2.34.2
Requirements check
This file should contain exactly five pinned package lines. Each line should match the reference above.
.venv/
.streamlit/secrets.toml
__pycache__/
Ignore file check
This file should exclude the environment folder and the local secret file. It should also exclude Python cache files.
OPENROUTER_API_KEY = "your-api-key-here"
Secret template check
This example should contain the required key name and the safe placeholder. It should contain no live credential.
import re
from pypdf import PdfReader
Matcher file check
This starter file should contain only the two imports shown above. Later steps build the matching functions below them.
import streamlit as st
st.title("Job Application Skill Matcher")
App file check
This starter app should import Streamlit and render the project title. The title text must match the reference exactly.
Before you run the app, ask yourself which title the browser should show.
- Start the local Streamlit app by running:
python -m streamlit run app.py
What does this command do?
The active Python interpreter starts Streamlit with app.py as the application entry point. Streamlit serves the app locally and opens it in your browser.
Your browser should display Job Application Skill Matcher as the page title. That is your first local win: the isolated environment can run the app successfully.
App not appearing in the browser?
Confirm that PowerShell still shows (.venv) at the start of the prompt. Check that app.py is saved directly inside job-skill-matcher.
Use help me launch my Streamlit app if the server still does not start.
Your Windows workspace is isolated and reproducible. Next, you'll turn this title into a resume input screen with synthetic text and PDF upload support.
Build the Resume Input Screen
Your starter app proves that Streamlit can run inside the isolated environment. It still has no text for the matcher to compare.
Before scoring a match, the app needs dependable resume input plus a job description. This step adds synthetic samples plus optional PDF extraction with clear feedback for files that contain no readable text.
In this step, get ready to:
- Render synthetic resume and job-description inputs.
- Extract an optional PDF in memory.
- Give clear feedback for readable or image-only PDFs.
Add the synthetic text inputs
Synthetic text gives the app safe default content for every test. It also keeps the input screen useful when no PDF is selected.
- In app.py, keep the existing import streamlit as st line.
- Replace everything below that import with this input screen:
SAMPLE_RESUME = """Data Analyst
Built ML pipelines in Python and pandas to forecast product demand.
Created SQL reports and automated weekly data-quality checks.
Deployed an internal Streamlit dashboard for operations teams.
Used Git and Docker to package and review analytics work.
"""
SAMPLE_JOB_DESCRIPTION = """We are hiring a Junior Machine Learning Engineer.
The role requires Python, machine learning, pandas, scikit-learn, SQL,
Streamlit, and AWS. The candidate should communicate analytical findings
and improve repeatable data workflows.
"""
st.title("Job Application Skill Matcher")
st.write("Compare a resume with a job description using local, explainable NLP plus optional AI coaching.")
resume_fallback = st.text_area("Resume text used when no PDF is uploaded", value=SAMPLE_RESUME, height=180)
job_description = st.text_area("Job description", value=SAMPLE_JOB_DESCRIPTION, height=220)
What does this input screen do?
- The SAMPLE_RESUME value gives you non-personal resume content for repeatable tests.
- The SAMPLE_JOB_DESCRIPTION value supplies the role requirements that the app compares later.
- The two st.text_area calls display editable text fields with the samples already loaded.
- The height values give each input enough room for several lines of text.
- Save app.py.
- Refresh the browser tab from earlier.
You should see the app description above two editable fields. The synthetic resume appears in the first field.
Your first input layer is working. Both sides of the future comparison now have safe default text.
Don't see the sample inputs?
- Confirm that import streamlit as st remains at the top of app.py.
- Compare the opening plus closing triple quotes around both sample strings.
Ask for help with the input screen.
Create the PDF extraction helper
The pypdf library reads an existing text layer from each PDF page. Streamlit keeps the upload in memory because this app never writes it to disk.
- In matcher.py, add the extraction helper below from pypdf import PdfReader by pasting this code:
def extract_pdf_text(uploaded_file):
uploaded_file.seek(0)
reader = PdfReader(uploaded_file)
text = "\n".join((page.extract_text() or "") for page in reader.pages)
if not text.strip():
raise ValueError(
"No extractable text was found. Use a text-based PDF or paste the resume text."
)
return text
What does this helper do?
- The uploaded_file.seek(0) call moves the file reader to the beginning of the upload.
- The PdfReader call opens the uploaded file directly from memory.
- The page.extract_text() calls collect readable text from every page.
- The if not text.strip() check rejects a file when no meaningful text was extracted.
- Save matcher.py.
- In app.py, find this current import:
import streamlit as st
What are you changing?
The current import gives app.py access to the Streamlit interface. The next import connects that interface to your new extraction helper.
- Replace the current import section with these lines:
import streamlit as st
from matcher import extract_pdf_text
How does the import help?
The extract_pdf_text import makes the local helper available inside app.py. The interface can now pass an in-memory upload to matcher.py.
- Save app.py.
- Refresh the browser tab from earlier.
You should see the two sample text areas reload successfully. This confirms that app.py can import the helper from matcher.py.
Good progress. Your interface now connects cleanly to the local PDF-processing code.
Does the app fail after the import?
- Confirm that the function is named extract_pdf_text in both files.
- Confirm that matcher.py is stored beside app.py inside job-skill-matcher.
Ask for help with the helper import.
✔️ Awesome, I've got everything!
Your PDF extraction helper matches the finished version for this step.
ⓧ I'd like to double check the full code
import re
from pypdf import PdfReader
def extract_pdf_text(uploaded_file):
uploaded_file.seek(0)
reader = PdfReader(uploaded_file)
text = "\n".join((page.extract_text() or "") for page in reader.pages)
if not text.strip():
raise ValueError(
"No extractable text was found. Use a text-based PDF or paste the resume text."
)
return text
Handle PDF uploads
The helper can read a PDF once app.py sends it the selected file. A guarded upload flow gives readable files a character count while turning extraction failures into clear feedback.
- In app.py, locate the current resume_fallback and job_description lines.
- Replace those two lines with this complete input flow:
uploaded_file = st.file_uploader(
"Upload a text-based PDF resume (optional)", type="pdf", max_upload_size=5
)
resume_fallback = st.text_area("Resume text used when no PDF is uploaded", value=SAMPLE_RESUME, height=180)
job_description = st.text_area("Job description", value=SAMPLE_JOB_DESCRIPTION, height=220)
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
How does the upload flow work?
- The st.file_uploader call accepts one optional PDF.
- The max_upload_size=5 setting gives this widget a small upload limit.
- The try block attempts to extract the file's text.
- The except block displays the extraction problem inside the app.
- The else block reports the number of extracted characters after a successful read.
✔️ Awesome, I've got everything!
Your input screen is ready for its final browser test.
ⓧ I'd like to double check the full code
import streamlit as st
from matcher import extract_pdf_text
SAMPLE_RESUME = """Data Analyst
Built ML pipelines in Python and pandas to forecast product demand.
Created SQL reports and automated weekly data-quality checks.
Deployed an internal Streamlit dashboard for operations teams.
Used Git and Docker to package and review analytics work.
"""
SAMPLE_JOB_DESCRIPTION = """We are hiring a Junior Machine Learning Engineer.
The role requires Python, machine learning, pandas, scikit-learn, SQL,
Streamlit, and AWS. The candidate should communicate analytical findings
and improve repeatable data workflows.
"""
st.title("Job Application Skill Matcher")
st.write("Compare a resume with a job description using local, explainable NLP plus optional AI coaching.")
uploaded_file = st.file_uploader(
"Upload a text-based PDF resume (optional)", type="pdf", max_upload_size=5
)
resume_fallback = st.text_area("Resume text used when no PDF is uploaded", value=SAMPLE_RESUME, height=180)
job_description = st.text_area("Job description", value=SAMPLE_JOB_DESCRIPTION, height=220)
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
Before you run the finished input screen, do you think the sample fields will remain visible before a file is selected? Hold that prediction.
- Save app.py.
- Return to Windows PowerShell from earlier.
- Stop the existing Streamlit server with Ctrl+C if it is still active.
- Start the updated app by running this command:
python -m streamlit run app.py
What does this command do?
This command launches app.py through the active Streamlit installation. The server keeps running until you stop it with Ctrl+C.
You should see the optional PDF upload above the two populated text areas. The synthetic resume plus job description remain visible without an upload.
The fallback path works exactly as intended. Your app is useful before any personal document enters the workflow.
Before you choose a PDF, do you expect the app to report the file size or the amount of text it can read? Hold that prediction.
- Select a non-sensitive text-based PDF through Upload a text-based PDF resume (optional).
You should see Extracted followed by a character count. This proves that the file contains a readable text layer.
An image-only PDF contains no searchable text layer. Before you test one, what should the app do with it? Hold that prediction.
- Replace the uploaded test file with an image-only PDF.
You should see No extractable text was found. Use a text-based PDF or paste the resume text. The app rejects the file instead of claiming that OCR occurred.
Don't see the expected PDF feedback?
- Confirm that your readable test PDF contains text you can select with the cursor.
- Confirm that extract_pdf_text(uploaded_file) appears inside the try block in app.py.
- Confirm that if not text.strip() appears inside extract_pdf_text in matcher.py.
Ask for help with PDF extraction.
Your input screen now supports safe synthetic data plus in-memory PDF extraction. Next, you'll turn those inputs into an explainable lexical similarity score.
Measure the Lexical Match
Your Streamlit input screen now accepts resume text from a synthetic sample or an uploaded PDF. It also holds the job description that the resume needs to match.
A lexical baseline gives you a transparent first score. You will represent the written vocabulary with TF-IDF before measuring the resulting vectors with cosine similarity.
This baseline compares terms exactly as they appear. The abbreviation ML creates a blind spot when the job description uses machine learning.
In this step, get ready to:
- Create a TF-IDF similarity function for the two text inputs.
- Display the lexical score as a percentage metric.
- Compare abbreviation wording with its full phrase.
Build the lexical similarity function
The scikit-learn vectorizer turns each document into a numeric vocabulary map. A larger cosine similarity means the two maps share more weighted terms.
- In the Visual Studio Code Explorer sidebar, select matcher.py.
- Place the two scikit-learn imports beneath from pypdf import PdfReader.
- Add lexical_similarity() beneath extract_pdf_text() using the code below:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def lexical_similarity(resume_text, job_description):
vectorizer = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
try:
matrix = vectorizer.fit_transform([resume_text, job_description])
except ValueError:
return 0.0
return float(cosine_similarity(matrix[0:1], matrix[1:2])[0, 0])
What does this function do?
- The TfidfVectorizer assigns more weight to useful vocabulary shared across the inputs.
- The stop_words="english" setting removes common English words that add little matching value.
- The ngram_range=(1, 2) setting includes individual words plus two-word phrases.
- The fit_transform() call learns one shared vocabulary from both documents.
- The cosine_similarity() call returns a score between zero and one.
- The ValueError fallback returns zero when the inputs contain no usable vocabulary.
- Save matcher.py.
- Switch back to the Streamlit browser tab from earlier.
You should still see both text areas plus the optional PDF uploader. This confirms that Streamlit can import the updated matcher.py file.
Does the input screen show an error?
Check that both scikit-learn imports sit at the top of matcher.py. Make sure lexical_similarity() starts at the left edge of the file.
Confirm that the virtual environment from earlier remains active in Windows PowerShell.
Help me fix the import or indentation problem in my matcher file.
✔️ Awesome, I've got everything!
Your matcher now has a reusable lexical similarity function.
ⓧ I'd like to double check the full code
import re
from pypdf import PdfReader
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def extract_pdf_text(uploaded_file):
uploaded_file.seek(0)
reader = PdfReader(uploaded_file)
text = "\n".join((page.extract_text() or "") for page in reader.pages)
if not text.strip():
raise ValueError(
"No extractable text was found. Use a text-based PDF or paste the resume text."
)
return text
def lexical_similarity(resume_text, job_description):
vectorizer = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
try:
matrix = vectorizer.fit_transform([resume_text, job_description])
except ValueError:
return 0.0
return float(cosine_similarity(matrix[0:1], matrix[1:2])[0, 0])
Connect resume text to the score
The app needs one resume value for every analysis. The extracted PDF text takes priority when it exists, while the synthetic text keeps the app usable without an upload.
- In app.py, find the block that starts with if uploaded_file is not None:.
- Replace that upload-handling block with the code below:
pdf_text = None
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
How is the resume selected?
The pdf_text value begins as None. A successful extraction replaces it with the text held in memory.
A failed extraction leaves the value empty. The app can still use the resume text area for its lexical comparison.
- Save app.py.
- Return to the Streamlit browser tab from earlier.
You should still see the synthetic resume plus the job description. A text-based PDF should still produce the extracted character count.
Did the input screen disappear?
Make sure pdf_text = None sits directly above the upload condition. Keep the try block indented inside that condition.
Help me check the PDF text block in app.py.
The selected resume text can now flow into the matching function. A button gives you a deliberate moment to run that comparison.
- At the top of app.py, add import matcher above from matcher import extract_pdf_text.
- Add the analysis button beneath the PDF extraction block using this code:
if st.button("Analyze Match", type="primary", width="stretch"):
resume_text = pdf_text if pdf_text is not None else resume_fallback
score = matcher.lexical_similarity(resume_text, job_description)
st.metric("Lexical similarity", f"{score:.1%}")
What happens when you click?
- The st.button() condition runs the comparison only after you choose to analyze the inputs.
- The resume_text value holds extracted PDF text when that text is available.
- The resume_fallback value supplies the synthetic resume in every other case.
- The st.metric() call formats the score as a percentage with one decimal place.
- Save app.py.
- Switch back to the Streamlit browser tab from earlier.
Before you click Analyze Match, do you expect the baseline to treat ML as the same phrase as machine learning?
- Click Analyze Match.
You will see a Lexical similarity percentage beneath the button. The baseline has compared the visible words without connecting ML to machine learning.
Is the metric missing?
Confirm that the button block sits below the PDF extraction block. Check that import matcher appears near the top of app.py.
Make sure the four lines inside the button condition share one level of indentation.
Help me find why the Lexical similarity metric is missing.
Expose the abbreviation blind spot
A controlled wording test changes one phrase while keeping every other sample detail fixed. Any score movement comes from the vocabulary change you made.
- In app.py, find this line inside SAMPLE_RESUME:
Built ML pipelines in Python and pandas to forecast product demand.
What is the baseline wording?
The resume uses the abbreviation ML. The job description uses the full phrase machine learning.
- Replace that line with the version below:
Built machine learning pipelines in Python and pandas to forecast product demand.
What changed in the sample?
The updated line now shares the exact phrase used by the job description. Every other resume detail stays the same.
- Save app.py.
- Return to the Streamlit browser tab from earlier.
Before you run the comparison again, do you expect the percentage to stay the same or move?
- Click Analyze Match again.
You will see a higher Lexical similarity percentage. That movement proves the baseline rewards exact vocabulary overlap.
Why did the percentage change?
Word-level TF-IDF treats ML as a different token from machine plus learning. The revised resume adds both shared words plus the shared two-word phrase.
This is the intended shortfall of the lexical baseline. The score responds to matching text without understanding that the original terms have the same meaning.
✔️ Awesome, I've got everything!
Your app now displays a lexical similarity percentage after each comparison.
ⓧ I'd like to double check the full code
import streamlit as st
import matcher
from matcher import extract_pdf_text
SAMPLE_RESUME = """Data Analyst
Built machine learning pipelines in Python and pandas to forecast product demand.
Created SQL reports and automated weekly data-quality checks.
Deployed an internal Streamlit dashboard for operations teams.
Used Git and Docker to package and review analytics work.
"""
SAMPLE_JOB_DESCRIPTION = """We are hiring a Junior Machine Learning Engineer.
The role requires Python, machine learning, pandas, scikit-learn, SQL,
Streamlit, and AWS. The candidate should communicate analytical findings
and improve repeatable data workflows.
"""
st.title("Job Application Skill Matcher")
st.write("Compare a resume with a job description using local, explainable NLP plus optional AI coaching.")
uploaded_file = st.file_uploader(
"Upload a text-based PDF resume (optional)", type="pdf", max_upload_size=5
)
resume_fallback = st.text_area("Resume text used when no PDF is uploaded", value=SAMPLE_RESUME, height=180)
job_description = st.text_area("Job description", value=SAMPLE_JOB_DESCRIPTION, height=220)
pdf_text = None
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
if st.button("Analyze Match", type="primary", width="stretch"):
resume_text = pdf_text if pdf_text is not None else resume_fallback
score = matcher.lexical_similarity(resume_text, job_description)
st.metric("Lexical similarity", f"{score:.1%}")
You have made the vocabulary gap visible through a controlled comparison. Next, you will add explicit vocabulary rules so equivalent skill terms contribute to the report.
Normalize Skills and Explain the Match
Your local Streamlit app now exposes the vocabulary gap in its TF-IDF baseline. The terms ML and machine learning describe the same skill while producing different lexical features.
This step adds alias normalization to close that gap. You will also classify skills and show the evidence behind each score.
In this step, get ready to:
- Normalize common skill aliases into canonical phrases.
- Calculate skill coverage and a transparent overall score.
- Display matched skills and missing skills with local resume evidence.
Normalize aliases with a skill taxonomy
A skill taxonomy gives the matcher a fixed vocabulary that you can inspect. Each category groups canonical skill names with the aliases that should count as the same skill.
- Switch back to matcher.py in Visual Studio Code.
- Add the skill taxonomy below the existing imports by pasting this code:
SKILL_CATEGORIES = {
"Programming": {
"Python": ["python"],
"SQL": ["sql"],
"JavaScript": ["javascript", "js"],
},
"Data and ML": {
"pandas": ["pandas"],
"scikit-learn": ["scikit-learn", "scikit learn", "sklearn"],
"Machine Learning": ["machine learning", "ml"],
"Natural Language Processing": ["natural language processing", "nlp"],
"Statistics": ["statistics", "statistical"],
},
"Cloud and DevOps": {
"AWS": ["aws", "amazon web services"],
"Azure": ["azure", "microsoft azure"],
"Docker": ["docker"],
"Git": ["git"],
},
"Analytics": {
"Power BI": ["power bi", "powerbi"],
"Tableau": ["tableau"],
"Excel": ["excel"],
},
"Apps and Databases": {
"Streamlit": ["streamlit"],
"PostgreSQL": ["postgresql", "postgres"],
"MySQL": ["mysql"],
},
}
How does the taxonomy work?
- Each outer key names a skill category that appears in the final report.
- Each inner key is the canonical label shown to the learner.
- Each list contains the phrases that count as evidence for that skill.
- The aliases machine learning and ml both support the canonical label Machine Learning.
- Save matcher.py.
- Return to the browser tab from earlier.
- Refresh the page to confirm the resume inputs and Analyze Match button still load.
The input screen still renders with the taxonomy in place. This confirms that Python can import the new dictionary.
Does the app show a Python error?
Check every closing brace in SKILL_CATEGORIES. Each category dictionary needs a trailing comma before the next category begins.
Use this prompt if the traceback points to the taxonomy. Help me find the syntax problem in my SKILL_CATEGORIES dictionary.
Phrase detection needs clear word boundaries. This prevents a short alias such as ml from matching those letters inside a longer word.
- Add has_phrase below SKILL_CATEGORIES by pasting this code:
def has_phrase(text, phrase):
pattern = rf"(?<!\w){re.escape(phrase.lower())}(?!\w)"
return re.search(pattern, text.lower()) is not None
What does phrase detection do?
- The regular expression escapes each alias before searching for it.
- The boundaries require the alias to appear as a complete phrase.
- Lowercasing both values makes the comparison case-insensitive.
- Save matcher.py.
- Refresh the browser to confirm the input screen still loads without an import error.
Your matcher can now detect aliases as complete phrases. The next helper rewrites those aliases before calculating the second similarity score.
- Add normalize_aliases below extract_pdf_text by pasting this code:
def normalize_aliases(text):
normalized = text.lower()
for skills in SKILL_CATEGORIES.values():
for aliases in skills.values():
canonical = aliases[0]
for alias in sorted(aliases[1:], key=len, reverse=True):
pattern = rf"(?<!\w){re.escape(alias)}(?!\w)"
normalized = re.sub(pattern, canonical, normalized)
return re.sub(r"\s+", " ", normalized).strip()
How does alias normalization work?
- The first alias in each list becomes the canonical phrase.
- Longer aliases are processed first to protect multi-word phrases.
- The final substitution reduces repeated whitespace to a single space.
- Both documents receive the same normalization before the alias-aware similarity calculation.
- Save matcher.py.
- Refresh the browser to confirm the resume inputs still appear.
The app still loads with the alias layer ready. Your current lexical metric remains available while the next substep builds the full result around it.
Does normalization stop the app loading?
Confirm that normalize_aliases sits after extract_pdf_text. Check that every line inside its loops uses four additional spaces of indentation.
Use this prompt to inspect the helper. Help me debug my normalize_aliases function in matcher.py.
Build an explainable analysis result
Similarity measures vocabulary overlap across both documents. Skill coverage answers a different question by counting how many required skills appear in the resume.
- Add skills_in_text below lexical_similarity by pasting this code:
def skills_in_text(text):
found = {}
for category, skills in SKILL_CATEGORIES.items():
found[category] = [
label
for label, aliases in skills.items()
if any(has_phrase(text, alias) for alias in aliases)
]
return found
What does skill extraction return?
The function checks every alias in the taxonomy. It returns canonical skill labels grouped under their original categories.
- Add aliases_for_skill immediately below skills_in_text by pasting this code:
def aliases_for_skill(skill_label):
for skills in SKILL_CATEGORIES.values():
if skill_label in skills:
return skills[skill_label]
return []
Why retrieve aliases again?
Evidence sentences can contain any known version of a skill name. This helper retrieves every accepted alias for the canonical label.
- Save matcher.py.
- Refresh the browser to confirm the input screen loads with both skill helpers.
The skill helpers now import successfully. The matcher can classify both documents with the same inspectable taxonomy.
Does skill extraction raise an error?
Check that skills_in_text and aliases_for_skill align with the left edge of the other function definitions. Nested function definitions can make them unavailable to analyze_match.
Use this prompt for focused help. Help me debug the skill helper functions in matcher.py.
An explainable report needs supporting text as well as labels. Evidence extraction keeps that support local by selecting resume sentences that contain matched skill aliases.
- Add relevant_evidence below aliases_for_skill by pasting this code:
def relevant_evidence(resume_text, matched_skills, limit=5):
segments = re.split(r"(?<=[.!?])\s+|\n+", resume_text)
evidence = []
for segment in segments:
cleaned = segment.strip()
if len(cleaned) < 12:
continue
if any(
has_phrase(cleaned, alias)
for skill in matched_skills
for alias in aliases_for_skill(skill)
):
evidence.append(cleaned)
if len(evidence) == limit:
break
return evidence
How is evidence selected?
- The regular expression splits the resume into sentences and lines.
- Very short fragments are skipped because they provide little context.
- A segment becomes evidence when it contains an alias for a matched skill.
- The limit keeps the report focused on the first five useful examples.
- Save matcher.py.
- Refresh the browser to confirm the app still accepts resume text and a job description.
Your local evidence helper now loads with the app. The report can connect each skill match to text from the resume.
The main analysis function combines the lexical baseline with the normalized score. It also compares required skills against resume skills before calculating coverage.
- Start analyze_match below relevant_evidence by pasting the first part of the function:
def analyze_match(resume_text, job_description):
lexical = lexical_similarity(resume_text, job_description)
alias_aware = lexical_similarity(
normalize_aliases(resume_text), normalize_aliases(job_description)
)
resume_skills = skills_in_text(resume_text)
required_skills = skills_in_text(job_description)
rows = []
matched_all = []
missing_all = []
for category, required in required_skills.items():
if not required:
continue
matched = [skill for skill in required if skill in resume_skills[category]]
missing = [skill for skill in required if skill not in resume_skills[category]]
matched_all.extend(matched)
missing_all.extend(missing)
rows.append(
{
"Skill category": category,
"Matched skills": ", ".join(matched) or "None",
"Missing skills": ", ".join(missing) or "None",
}
)
What does the first part calculate?
- The original lexical score remains unchanged for comparison.
- The alias-aware score runs the same similarity function on normalized text.
- Required skills are divided into matched and missing lists for each category.
- Each category becomes one row for the report table.
- Complete the same analyze_match function by adding this code directly below the first part:
required_count = len(matched_all) + len(missing_all)
coverage = len(matched_all) / required_count if required_count else 0.0
overall = 0.5 * alias_aware + 0.5 * coverage
return {
"lexical_similarity": round(lexical * 100, 1),
"alias_similarity": round(alias_aware * 100, 1),
"skill_coverage": round(coverage * 100, 1),
"overall_score": round(overall * 100, 1),
"matched_skills": matched_all,
"missing_skills": missing_all,
"category_rows": rows,
"evidence": relevant_evidence(resume_text, matched_all),
}
How are the final scores built?
- Skill coverage divides the number of matched requirements by the total number of detected requirements.
- The overall score gives equal weight to alias-aware similarity and skill coverage.
- Every percentage is rounded to one decimal place before reaching the interface.
- The returned dictionary also carries the category rows and local evidence.
How should you interpret the overall score?
Treat the overall score as a project-defined teaching signal. Its equal weighting makes the formula easy to inspect and explain.
Hiring decisions need broader evidence from people and structured evaluation. This score only summarizes the two signals implemented in this project.
- Save matcher.py.
- Refresh the browser to confirm the app still loads after importing the complete analysis function.
The input screen still loads after the full analysis pipeline is defined. That successful import confirms the helper chain is syntactically complete.
Does analyze_match stop the app loading?
Confirm that the second code section remains indented inside analyze_match. The lines beginning with required_count and return need four leading spaces.
Use this prompt if the traceback points into the function. Help me debug the analyze_match function in matcher.py.
✔️ Awesome, I've got everything!
Great. Save matcher.py before updating the interface.
ⓧ I'd like to double check the full code
import re
from pypdf import PdfReader
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
SKILL_CATEGORIES = {
"Programming": {
"Python": ["python"],
"SQL": ["sql"],
"JavaScript": ["javascript", "js"],
},
"Data and ML": {
"pandas": ["pandas"],
"scikit-learn": ["scikit-learn", "scikit learn", "sklearn"],
"Machine Learning": ["machine learning", "ml"],
"Natural Language Processing": ["natural language processing", "nlp"],
"Statistics": ["statistics", "statistical"],
},
"Cloud and DevOps": {
"AWS": ["aws", "amazon web services"],
"Azure": ["azure", "microsoft azure"],
"Docker": ["docker"],
"Git": ["git"],
},
"Analytics": {
"Power BI": ["power bi", "powerbi"],
"Tableau": ["tableau"],
"Excel": ["excel"],
},
"Apps and Databases": {
"Streamlit": ["streamlit"],
"PostgreSQL": ["postgresql", "postgres"],
"MySQL": ["mysql"],
},
}
def has_phrase(text, phrase):
pattern = rf"(?<!\w){re.escape(phrase.lower())}(?!\w)"
return re.search(pattern, text.lower()) is not None
def extract_pdf_text(uploaded_file):
uploaded_file.seek(0)
reader = PdfReader(uploaded_file)
text = "\n".join((page.extract_text() or "") for page in reader.pages)
if not text.strip():
raise ValueError(
"No extractable text was found. Use a text-based PDF or paste the resume text."
)
return text
def normalize_aliases(text):
normalized = text.lower()
for skills in SKILL_CATEGORIES.values():
for aliases in skills.values():
canonical = aliases[0]
for alias in sorted(aliases[1:], key=len, reverse=True):
pattern = rf"(?<!\w){re.escape(alias)}(?!\w)"
normalized = re.sub(pattern, canonical, normalized)
return re.sub(r"\s+", " ", normalized).strip()
def lexical_similarity(resume_text, job_description):
vectorizer = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
try:
matrix = vectorizer.fit_transform([resume_text, job_description])
except ValueError:
return 0.0
return float(cosine_similarity(matrix[0:1], matrix[1:2])[0, 0])
def skills_in_text(text):
found = {}
for category, skills in SKILL_CATEGORIES.items():
found[category] = [
label
for label, aliases in skills.items()
if any(has_phrase(text, alias) for alias in aliases)
]
return found
def aliases_for_skill(skill_label):
for skills in SKILL_CATEGORIES.values():
if skill_label in skills:
return skills[skill_label]
return []
def relevant_evidence(resume_text, matched_skills, limit=5):
segments = re.split(r"(?<=[.!?])\s+|\n+", resume_text)
evidence = []
for segment in segments:
cleaned = segment.strip()
if len(cleaned) < 12:
continue
if any(
has_phrase(cleaned, alias)
for skill in matched_skills
for alias in aliases_for_skill(skill)
):
evidence.append(cleaned)
if len(evidence) == limit:
break
return evidence
def analyze_match(resume_text, job_description):
lexical = lexical_similarity(resume_text, job_description)
alias_aware = lexical_similarity(
normalize_aliases(resume_text), normalize_aliases(job_description)
)
resume_skills = skills_in_text(resume_text)
required_skills = skills_in_text(job_description)
rows = []
matched_all = []
missing_all = []
for category, required in required_skills.items():
if not required:
continue
matched = [skill for skill in required if skill in resume_skills[category]]
missing = [skill for skill in required if skill not in resume_skills[category]]
matched_all.extend(matched)
missing_all.extend(missing)
rows.append(
{
"Skill category": category,
"Matched skills": ", ".join(matched) or "None",
"Missing skills": ", ".join(missing) or "None",
}
)
required_count = len(matched_all) + len(missing_all)
coverage = len(matched_all) / required_count if required_count else 0.0
overall = 0.5 * alias_aware + 0.5 * coverage
return {
"lexical_similarity": round(lexical * 100, 1),
"alias_similarity": round(alias_aware * 100, 1),
"skill_coverage": round(coverage * 100, 1),
"overall_score": round(overall * 100, 1),
"matched_skills": matched_all,
"missing_skills": missing_all,
"category_rows": rows,
"evidence": relevant_evidence(resume_text, matched_all),
}
Render and verify the local report
The interface now needs to unpack the analysis dictionary into visible sections. A pandas DataFrame turns the category rows into an inspectable table.
- Switch back to app.py.
- Replace the import section at the top of app.py with this code:
import pandas as pd
import streamlit as st
from matcher import analyze_match, extract_pdf_text
Why do the imports change?
- The interface now calls analyze_match to receive every local result in one dictionary.
- The pd alias creates the category table from the returned rows.
- PDF extraction stays available through the existing extract_pdf_text import.
- Find the existing block that begins with the Analyze Match button.
- Replace that block with the first part of the new report interface:
if st.button("Analyze Match", type="primary", width="stretch"):
resume_text = pdf_text if uploaded_file is not None else resume_fallback
if not resume_text or not resume_text.strip():
st.error("Provide extractable resume text before running the analysis.")
elif not job_description.strip():
st.error("Paste a job description before running the analysis.")
else:
result = analyze_match(resume_text, job_description)
st.subheader("Match report")
lexical_col, alias_col, coverage_col, overall_col = st.columns(4)
lexical_col.metric("Lexical similarity", f"{result['lexical_similarity']}%")
alias_col.metric("Alias-aware similarity", f"{result['alias_similarity']}%")
coverage_col.metric("Skill coverage", f"{result['skill_coverage']}%")
overall_col.metric("Overall match", f"{result['overall_score']}%")
st.warning(
"The overall score is a project-defined blend, not a standardized "
"recruiting or hiring score."
)
What does the metric row show?
- The lexical metric preserves the original baseline.
- The alias-aware metric shows the effect of canonical vocabulary.
- Skill coverage reports the share of detected job requirements found in the resume.
- Overall match displays the equal blend calculated in analyze_match.
- Save app.py.
- Return to the browser tab from earlier.
- Refresh the page.
- Click Analyze Match.
You should see four percentage metrics under Match report. The alias-aware result now uses normalized vocabulary.
Do the four metrics fail to appear?
Confirm that app.py imports analyze_match. Check that each metric key matches the dictionary returned from matcher.py.
Use this prompt if the report stops at the button. Help me debug the four local match metrics in app.py.
The score row summarizes the match. The remaining interface exposes the categories and evidence that produced those numbers.
- Add the detailed report directly below the project-defined-score warning by pasting this code:
st.subheader("Skills by category")
if result["category_rows"]:
st.dataframe(pd.DataFrame(result["category_rows"]), hide_index=True)
else:
st.write("No skills from the current taxonomy were found in the job description.")
st.subheader("Matched and missing skills")
st.write("Matched:", ", ".join(result["matched_skills"]) or "None")
st.write("Missing:", ", ".join(result["missing_skills"]) or "None")
with st.expander("Relevant resume evidence", expanded=True):
if result["evidence"]:
for sentence in result["evidence"]:
st.write(f"- {sentence}")
else:
st.write("No evidence sentence matched the current skill taxonomy.")
How does the report stay explainable?
- The table shows which requirements belong to each skill category.
- The matched and missing lists expose the exact labels used for coverage.
- The evidence expander shows local resume sentences that support detected matches.
- The report can be inspected without sending either source document to an external service.
- Restore ML in the synthetic resume if the previous test changed it to machine learning.
- Save app.py.
✔️ Awesome, I've got everything!
Your local report interface is complete. Save app.py before the final test.
ⓧ I'd like to double check the full code
import pandas as pd
import streamlit as st
from matcher import analyze_match, extract_pdf_text
SAMPLE_RESUME = """Data Analyst
Built ML pipelines in Python and pandas to forecast product demand.
Created SQL reports and automated weekly data-quality checks.
Deployed an internal Streamlit dashboard for operations teams.
Used Git and Docker to package and review analytics work.
"""
SAMPLE_JOB_DESCRIPTION = """We are hiring a Junior Machine Learning Engineer.
The role requires Python, machine learning, pandas, scikit-learn, SQL,
Streamlit, and AWS. The candidate should communicate analytical findings
and improve repeatable data workflows.
"""
st.title("Job Application Skill Matcher")
st.write(
"Compare a resume with a job description using local, explainable NLP "
"plus optional AI coaching."
)
st.warning(
"Use the synthetic sample or anonymized text for this learning project. "
"The OpenRouter request receives scores and skill names, not raw resume "
"or job-description text."
)
uploaded_file = st.file_uploader(
"Upload a text-based PDF resume (optional)", type="pdf", max_upload_size=5
)
resume_fallback = st.text_area(
"Resume text used when no PDF is uploaded",
value=SAMPLE_RESUME,
height=180,
)
job_description = st.text_area(
"Job description",
value=SAMPLE_JOB_DESCRIPTION,
height=220,
)
pdf_text = None
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
if st.button("Analyze Match", type="primary", width="stretch"):
resume_text = pdf_text if uploaded_file is not None else resume_fallback
if not resume_text or not resume_text.strip():
st.error("Provide extractable resume text before running the analysis.")
elif not job_description.strip():
st.error("Paste a job description before running the analysis.")
else:
result = analyze_match(resume_text, job_description)
st.subheader("Match report")
lexical_col, alias_col, coverage_col, overall_col = st.columns(4)
lexical_col.metric("Lexical similarity", f"{result['lexical_similarity']}%")
alias_col.metric("Alias-aware similarity", f"{result['alias_similarity']}%")
coverage_col.metric("Skill coverage", f"{result['skill_coverage']}%")
overall_col.metric("Overall match", f"{result['overall_score']}%")
st.warning(
"The overall score is a project-defined blend, not a standardized "
"recruiting or hiring score."
)
st.subheader("Skills by category")
if result["category_rows"]:
st.dataframe(pd.DataFrame(result["category_rows"]), hide_index=True)
else:
st.write("No skills from the current taxonomy were found in the job description.")
st.subheader("Matched and missing skills")
st.write("Matched:", ", ".join(result["matched_skills"]) or "None")
st.write("Missing:", ", ".join(result["missing_skills"]) or "None")
with st.expander("Relevant resume evidence", expanded=True):
if result["evidence"]:
for sentence in result["evidence"]:
st.write(f"- {sentence}")
else:
st.write("No evidence sentence matched the current skill taxonomy.")
- Return to the PowerShell terminal from earlier.
- Stop the existing Streamlit process by pressing Ctrl+C.
Before you run the app, make your prediction about whether ML now produces a Machine Learning match and a higher alias-aware score.
- Restart the local app by running this command:
python -m streamlit run app.py
What does this check prove?
This command reloads both updated Python files in a fresh process. A successful page load confirms that the interface can import the complete local analysis pipeline.
- Return to the browser tab opened by Streamlit.
- Confirm that the synthetic resume contains ML.
- Click Analyze Match.
You should see Machine Learning among the matched skills. The Alias-aware similarity value should be higher than the Lexical similarity baseline.
You should also see skill coverage and overall match metrics. The category table and evidence expander make those results traceable to named skills and resume sentences.
Is Machine Learning still missing?
Confirm that the Machine Learning alias list contains machine learning first and ml second. Check that analyze_match normalizes both texts before calculating alias_aware.
Use this prompt to trace the mismatch. Help me debug why ML is missing from my alias-aware skill report.
Your local report now explains its scores through categories and evidence. Next, you will add optional OpenRouter coaching while keeping the deterministic analysis in control.
Add Privacy-Aware AI Recommendations
Your local Streamlit matcher now explains where a resume matches a role. Its transparent report remains useful without an external service.
OpenRouter adds optional coaching that turns skill gaps into next actions. Privacy by design keeps raw resume text on your computer.
The local report remains the scoring authority. A failed external request leaves that report intact.
In this step, get ready to:
- Keep your OpenRouter API key in the ignored local secrets file.
- Build a privacy-safe recommendation request from local match signals.
- Render optional coaching without disrupting the deterministic report.
Protect the API key
A local secrets file keeps credentials out of your source code. Your existing .gitignore entry prevents the secret file from entering Git history.
- Switch back to .streamlit/secrets.toml in Visual Studio Code.
- Replace your-api-key-here with your existing OpenRouter API key.
- Save .streamlit/secrets.toml.
- Return to .gitignore from earlier.
- Confirm that .streamlit/secrets.toml remains listed in the file.
That sensitive part is handled. Your key stays in the ignored local file while the application reads it at runtime.
Send only local match signals
The Requests library sends the optional API call. A small JSON payload carries scores plus skill names.
- In matcher.py, replace the import block at the top with the following imports plus API constants:
import json
import re
import requests
from pypdf import PdfReader
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
FREE_MODEL = "openrouter/free"
What does this setup provide?
- The json module converts the local signals into text for the user message.
- The requests library sends the API request.
- OPENROUTER_URL identifies the chat-completions endpoint.
- FREE_MODEL selects the free model router.
- At the end of matcher.py, start get_ai_recommendations with the privacy-safe signal dictionary below:
def get_ai_recommendations(api_key, result):
signals = {
"overall_match_percent": result["overall_score"],
"alias_similarity_percent": result["alias_similarity"],
"skill_coverage_percent": result["skill_coverage"],
"matched_skills": result["matched_skills"],
"missing_skills": result["missing_skills"],
}
Why is this payload privacy-aware?
- The payload includes the project-defined overall score.
- The payload includes alias-aware similarity plus skill coverage.
- The payload includes matched plus missing skill names.
- Raw resume text stays outside the payload. Raw job-description text stays outside the payload.
- Continue inside get_ai_recommendations by placing this request directly below the signals dictionary:
response = requests.post(
url=OPENROUTER_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": FREE_MODEL,
"messages": [
{
"role": "system",
"content": (
"You are a careful job application coach. Use only the supplied "
"scores and skill names. Do not invent experience, employers, "
"qualifications, or achievements. Return exactly three concise "
"Markdown bullet points with practical improvement suggestions."
),
},
{
"role": "user",
"content": json.dumps(signals, indent=2),
},
],
},
timeout=45,
)
What does this request do?
- The authorization header sends the key without placing it in the payload.
- The system message limits the model to the supplied signals.
- The user message contains the serialized signals dictionary.
- The prompt requests exactly three practical suggestions.
- Finish get_ai_recommendations by placing this response handling directly below the request:
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
if not content:
raise ValueError("The selected model returned an empty response.")
return content, data["model"]
How is the response checked?
- response.raise_for_status() surfaces an unsuccessful response for the interface to handle.
- response.json() decodes the returned data.
- The empty-content check prevents a blank result from appearing as successful coaching.
- The return value includes the suggestions plus the routed model name.
✔️ Awesome, I've got everything!
Your privacy-safe request function is complete.
ⓧ I'd like to double check the full code
import json
import re
import requests
from pypdf import PdfReader
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
FREE_MODEL = "openrouter/free"
SKILL_CATEGORIES = {
"Programming": {
"Python": ["python"],
"SQL": ["sql"],
"JavaScript": ["javascript", "js"],
},
"Data and ML": {
"pandas": ["pandas"],
"scikit-learn": ["scikit-learn", "scikit learn", "sklearn"],
"Machine Learning": ["machine learning", "ml"],
"Natural Language Processing": ["natural language processing", "nlp"],
"Statistics": ["statistics", "statistical"],
},
"Cloud and DevOps": {
"AWS": ["aws", "amazon web services"],
"Azure": ["azure", "microsoft azure"],
"Docker": ["docker"],
"Git": ["git"],
},
"Analytics": {
"Power BI": ["power bi", "powerbi"],
"Tableau": ["tableau"],
"Excel": ["excel"],
},
"Apps and Databases": {
"Streamlit": ["streamlit"],
"PostgreSQL": ["postgresql", "postgres"],
"MySQL": ["mysql"],
},
}
def has_phrase(text, phrase):
pattern = rf"(?<!\w){re.escape(phrase.lower())}(?!\w)"
return re.search(pattern, text.lower()) is not None
def extract_pdf_text(uploaded_file):
uploaded_file.seek(0)
reader = PdfReader(uploaded_file)
text = "\n".join((page.extract_text() or "") for page in reader.pages)
if not text.strip():
raise ValueError(
"No extractable text was found. Use a text-based PDF or paste the resume text."
)
return text
def normalize_aliases(text):
normalized = text.lower()
for skills in SKILL_CATEGORIES.values():
for aliases in skills.values():
canonical = aliases[0]
for alias in sorted(aliases[1:], key=len, reverse=True):
pattern = rf"(?<!\w){re.escape(alias)}(?!\w)"
normalized = re.sub(pattern, canonical, normalized)
return re.sub(r"\s+", " ", normalized).strip()
def lexical_similarity(resume_text, job_description):
vectorizer = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
try:
matrix = vectorizer.fit_transform([resume_text, job_description])
except ValueError:
return 0.0
return float(cosine_similarity(matrix[0:1], matrix[1:2])[0, 0])
def skills_in_text(text):
found = {}
for category, skills in SKILL_CATEGORIES.items():
found[category] = [
label
for label, aliases in skills.items()
if any(has_phrase(text, alias) for alias in aliases)
]
return found
def aliases_for_skill(skill_label):
for skills in SKILL_CATEGORIES.values():
if skill_label in skills:
return skills[skill_label]
return []
def relevant_evidence(resume_text, matched_skills, limit=5):
segments = re.split(r"(?<=[.!?])\s+|\n+", resume_text)
evidence = []
for segment in segments:
cleaned = segment.strip()
if len(cleaned) < 12:
continue
if any(
has_phrase(cleaned, alias)
for skill in matched_skills
for alias in aliases_for_skill(skill)
):
evidence.append(cleaned)
if len(evidence) == limit:
break
return evidence
def analyze_match(resume_text, job_description):
lexical = lexical_similarity(resume_text, job_description)
alias_aware = lexical_similarity(
normalize_aliases(resume_text), normalize_aliases(job_description)
)
resume_skills = skills_in_text(resume_text)
required_skills = skills_in_text(job_description)
rows = []
matched_all = []
missing_all = []
for category, required in required_skills.items():
if not required:
continue
matched = [skill for skill in required if skill in resume_skills[category]]
missing = [skill for skill in required if skill not in resume_skills[category]]
matched_all.extend(matched)
missing_all.extend(missing)
rows.append(
{
"Skill category": category,
"Matched skills": ", ".join(matched) or "None",
"Missing skills": ", ".join(missing) or "None",
}
)
required_count = len(matched_all) + len(missing_all)
coverage = len(matched_all) / required_count if required_count else 0.0
overall = 0.5 * alias_aware + 0.5 * coverage
return {
"lexical_similarity": round(lexical * 100, 1),
"alias_similarity": round(alias_aware * 100, 1),
"skill_coverage": round(coverage * 100, 1),
"overall_score": round(overall * 100, 1),
"matched_skills": matched_all,
"missing_skills": missing_all,
"category_rows": rows,
"evidence": relevant_evidence(resume_text, matched_all),
}
def get_ai_recommendations(api_key, result):
signals = {
"overall_match_percent": result["overall_score"],
"alias_similarity_percent": result["alias_similarity"],
"skill_coverage_percent": result["skill_coverage"],
"matched_skills": result["matched_skills"],
"missing_skills": result["missing_skills"],
}
response = requests.post(
url=OPENROUTER_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": FREE_MODEL,
"messages": [
{
"role": "system",
"content": (
"You are a careful job application coach. Use only the supplied "
"scores and skill names. Do not invent experience, employers, "
"qualifications, or achievements. Return exactly three concise "
"Markdown bullet points with practical improvement suggestions."
),
},
{
"role": "user",
"content": json.dumps(signals, indent=2),
},
],
},
timeout=45,
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
if not content:
raise ValueError("The selected model returned an empty response.")
return content, data["model"]
Keep coaching optional
The local report already renders before this new interface block runs. The coaching section catches request failures so they cannot erase deterministic results.
- In app.py, replace the imports at the top with this updated block:
import pandas as pd
import requests
import streamlit as st
from matcher import analyze_match, extract_pdf_text, get_ai_recommendations
Why update these imports?
- The requests import provides the request exception type used by the interface.
- The get_ai_recommendations import connects the privacy-safe helper to the app.
- At the end of the successful analysis branch in app.py, place this coaching block directly below the evidence expander:
st.subheader("AI improvement suggestions")
try:
api_key = st.secrets["OPENROUTER_API_KEY"]
except (FileNotFoundError, KeyError):
api_key = ""
if not api_key or api_key == "your-api-key-here":
st.warning(
"Add OPENROUTER_API_KEY to .streamlit/secrets.toml to enable "
"optional AI suggestions."
)
else:
try:
recommendations, model_used = get_ai_recommendations(api_key, result)
except requests.RequestException as exc:
st.error(f"OpenRouter request failed: {exc}")
except (KeyError, TypeError, ValueError) as exc:
st.error(f"OpenRouter returned an unexpected response: {exc}")
else:
st.markdown(recommendations)
st.write("Free model selected:", model_used)
st.warning(
"Verify every suggestion before editing a resume. The model has "
"not seen your raw experience and must not be used to invent claims."
)
How does optional coaching behave?
- st.secrets reads the key from the ignored local file.
- A missing key produces a warning while preserving the local report.
- Request errors plus unexpected responses become clear messages inside the coaching section.
- A successful response shows three suggestions plus the routed model name.
- The final warning requires every suggestion to be checked before it affects a resume.
✔️ Awesome, I've got everything!
Your complete interface keeps local analysis visible while optional coaching runs.
ⓧ I'd like to double check the full code
import pandas as pd
import requests
import streamlit as st
from matcher import analyze_match, extract_pdf_text, get_ai_recommendations
SAMPLE_RESUME = """Data Analyst
Built ML pipelines in Python and pandas to forecast product demand.
Created SQL reports and automated weekly data-quality checks.
Deployed an internal Streamlit dashboard for operations teams.
Used Git and Docker to package and review analytics work.
"""
SAMPLE_JOB_DESCRIPTION = """We are hiring a Junior Machine Learning Engineer.
The role requires Python, machine learning, pandas, scikit-learn, SQL,
Streamlit, and AWS. The candidate should communicate analytical findings
and improve repeatable data workflows.
"""
st.title("Job Application Skill Matcher")
st.write(
"Compare a resume with a job description using local, explainable NLP "
"plus optional AI coaching."
)
st.warning(
"Use the synthetic sample or anonymized text for this learning project. "
"The OpenRouter request receives scores and skill names, not raw resume "
"or job-description text."
)
uploaded_file = st.file_uploader(
"Upload a text-based PDF resume (optional)", type="pdf", max_upload_size=5
)
resume_fallback = st.text_area(
"Resume text used when no PDF is uploaded",
value=SAMPLE_RESUME,
height=180,
)
job_description = st.text_area(
"Job description",
value=SAMPLE_JOB_DESCRIPTION,
height=220,
)
pdf_text = None
if uploaded_file is not None:
try:
pdf_text = extract_pdf_text(uploaded_file)
except (ValueError, OSError) as exc:
st.error(str(exc))
else:
st.success(f"Extracted {len(pdf_text):,} characters from the PDF.")
if st.button("Analyze Match", type="primary", width="stretch"):
resume_text = pdf_text if uploaded_file is not None else resume_fallback
if not resume_text or not resume_text.strip():
st.error("Provide extractable resume text before running the analysis.")
elif not job_description.strip():
st.error("Paste a job description before running the analysis.")
else:
result = analyze_match(resume_text, job_description)
st.subheader("Match report")
lexical_col, alias_col, coverage_col, overall_col = st.columns(4)
lexical_col.metric("Lexical similarity", f"{result['lexical_similarity']}%")
alias_col.metric("Alias-aware similarity", f"{result['alias_similarity']}%")
coverage_col.metric("Skill coverage", f"{result['skill_coverage']}%")
overall_col.metric("Overall match", f"{result['overall_score']}%")
st.warning(
"The overall score is a project-defined blend, not a standardized "
"recruiting or hiring score."
)
st.subheader("Skills by category")
if result["category_rows"]:
st.dataframe(pd.DataFrame(result["category_rows"]), hide_index=True)
else:
st.write("No skills from the current taxonomy were found in the job description.")
st.subheader("Matched and missing skills")
st.write("Matched:", ", ".join(result["matched_skills"]) or "None")
st.write("Missing:", ", ".join(result["missing_skills"]) or "None")
with st.expander("Relevant resume evidence", expanded=True):
if result["evidence"]:
for sentence in result["evidence"]:
st.write(f"- {sentence}")
else:
st.write("No evidence sentence matched the current skill taxonomy.")
st.subheader("AI improvement suggestions")
try:
api_key = st.secrets["OPENROUTER_API_KEY"]
except (FileNotFoundError, KeyError):
api_key = ""
if not api_key or api_key == "your-api-key-here":
st.warning(
"Add OPENROUTER_API_KEY to .streamlit/secrets.toml to enable "
"optional AI suggestions."
)
else:
try:
recommendations, model_used = get_ai_recommendations(api_key, result)
except requests.RequestException as exc:
st.error(f"OpenRouter request failed: {exc}")
except (KeyError, TypeError, ValueError) as exc:
st.error(f"OpenRouter returned an unexpected response: {exc}")
else:
st.markdown(recommendations)
st.write("Free model selected:", model_used)
st.warning(
"Verify every suggestion before editing a resume. The model has "
"not seen your raw experience and must not be used to invent claims."
)
- Save matcher.py.
- Save app.py.
- Return to the Windows PowerShell panel with the activated .venv.
- Stop the existing Streamlit server with Ctrl+C if it is still running.
Before you run the finished app, do you think an unavailable free model can erase the local match report?
- Start the completed app by running this command:
python -m streamlit run app.py
- Return to the browser tab showing Job Application Skill Matcher.
- Click Analyze Match.
You will see the deterministic match report before the optional coaching section. This confirms that local scoring does not depend on the external request.
When a free model is available, you will see three recommendations plus the selected model name. An API failure leaves the local report visible with a clear error message.
AI suggestions not appearing?
- If the app asks for a key, confirm that OPENROUTER_API_KEY is present in .streamlit/secrets.toml.
- If the request fails, keep using the local report. Free-model availability plus rate limits can vary.
Help me diagnose the optional OpenRouter recommendation section without exposing my API key.
Strong finish. Your completed matcher provides explainable local scoring plus privacy-aware coaching without making external AI the decision-maker.
Secret mission
Test Character N-Gram Robustness
Test how three text representations respond to abbreviations and spelling mistakes. Your new script compares word, alias-aware, and character n-gram scores for two focused cases.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the local project, pause its running processes, or delete its files. This project has no ongoing service charge because its local resources only use disk space.
Resources you used:
- The job-skill-matcher folder on your Desktop.
- A disposable .venv virtual environment inside the project folder.
- A .streamlit/secrets.toml file containing your local OpenRouter credential.
- A local Streamlit server process if the app is still running.
Keep everything running
No action is needed. Choose this if you want to keep improving the matcher or rerun its robustness tests.
- Keep the job-skill-matcher folder on your Desktop.
- Keep .streamlit/secrets.toml private because it contains a live API key.
- Keep .gitignore unchanged so Git continues excluding the local secret file.
The OpenRouter free router has zero model cost. Availability and rate limits can still vary when you request suggestions.
Pause - I'll come back to this later
Stop the local server to free its memory while keeping every project file ready for later. Your saved code and virtual environment remain unchanged.
- Return to the Windows PowerShell window running the Streamlit app.
- Press Ctrl+C to stop the server.
You will see the PowerShell prompt return. This confirms the local server has stopped.
- Leave the virtual environment by running this command:
deactivate
What Does This Command Do?
This returns PowerShell to your regular Python environment. The project files and .venv stay on disk for your next session.
You will see the prompt without the (.venv) environment marker. No local server or API call continues after the Streamlit process stops.
Delete - I don't want to use this again
Deleting the folder permanently removes every local project file. These steps target only job-skill-matcher so the rest of your Desktop stays untouched.
Your API Key Needs Separate Cleanup
Deleting .streamlit/secrets.toml removes the local copy of your key. OpenRouter keeps the original key active until you revoke it.
Stop the app before removing its files:
- Return to the PowerShell window running the Streamlit app.
- Press Ctrl+C if the server is still running.
- Close the PowerShell window.
Remove the folder from your Desktop:
- Press the Windows key to open search.
- Type File Explorer.
- Press Enter to open File Explorer.
- Select Desktop in the left sidebar.
- Right-click the job-skill-matcher folder.
- Select Delete.
The folder now sits in the Recycle Bin. The project folder is gone from your Desktop.
Remove the local files permanently:
- Press the Windows key to open search.
- Type Recycle Bin.
- Press Enter to open the Recycle Bin.
- Select the job-skill-matcher folder.
- Press the Delete key.
- Confirm the permanent deletion.
You should no longer see job-skill-matcher in the Recycle Bin. This confirms that the project code, virtual environment, and local secret file are deleted.
This extra cleanup applies only if you created a dedicated API key for this project. Keep a reused key active if other projects still depend on it.
Revoke a dedicated API key:
- Sign in to OpenRouter.
- Open the API key management page from your account.
- Locate the dedicated project key.
- Revoke the dedicated project key.
- Confirm that the key no longer appears as active.
Nice Work!
Nice Work!
That's your privacy-aware job application skill matcher complete! Your local Streamlit app produces an explainable report before optionally sending only scores and skill names to OpenRouter.
You've learned how to:
- Build a local resume input workflow for synthetic text or a text-based PDF. Keep uploaded documents in memory during text extraction.
- Create an explainable matching report with TF-IDF and cosine similarity. Expose the ML vocabulary gap before fixing it with alias normalization. Show matched skills, missing skills, evidence, skill coverage, and a transparent overall score.
- Keep the local report useful when optional AI coaching is unavailable. Send only non-personal scores and skill names when requesting three recommendations.
- Complete the optional Secret Mission by comparing word TF-IDF, alias-aware TF-IDF, and character n-gram matching on abbreviations and typos.
Ready to quiz yourself?