Education › AI Engineering & AIOps › Guided project

SkillMatch AI: from semester project to shipped app

Take a Streamlit career recommender built on Sentence-BERT embeddings and cosine similarity — a typical course project that only runs on its author's laptop — and turn it into something anyone can open in a browser: reproducible installs, tests, a public demo on Hugging Face Spaces, a container, automated deploys from GitHub, an evaluation gate in CI, and basic usage monitoring.

Intermediate about 6 hours 6 phases · 39 steps 0 / 39 done
What you will have at the end

A live public URL on Hugging Face Spaces where anyone can paste their skills and get ranked careers with a skill-gap breakdown; a GitHub repository (public or private, your choice) with pinned dependencies, tests, a Dockerfile, a CI workflow that runs the evaluation harness and redeploys the Space on every merge, and a README with an "Open in Spaces" badge. The pattern transfers to any Streamlit or Gradio ML app.

Before you start
  • The SkillMatch AI repository (a Streamlit app with app.py, src/, data/careers.csv, evaluate.py and requirements.txt); any similar Streamlit ML app works the same way
  • Git, Python 3.11 or 3.12, and Docker Desktop installed (the DevOps zero-to-production project covers installation)
  • A free GitHub account and a free Hugging Face account
  • About 2 GB of disk for the PyTorch CPU wheel and the model cache
Tools you will install
  • Hugging Face Spaces — free CPU hosting for Streamlit apps with a public URL, built straight from a git repository ↗
  • Streamlit — the app's UI framework; Spaces has a native Streamlit runtime ↗
  • sentence-transformers + PyTorch (CPU) — the embedding model; the CPU wheel keeps installs small and fast ↗
  • pytest — tests for the recommender and the evaluation gate in CI ↗
  • GitHub Actions — run tests on pull requests and push to the Space on merge ↗
  • Docker — a portable image for anyone who wants to run it without Python, and an alternative Space type ↗
Repository layout at the end
skillmatch-ai/
├── .github/workflows/
│   ├── ci.yml              # tests + evaluation gate on every PR
│   └── deploy.yml          # push to the Hugging Face Space on merge to main
├── .streamlit/
│   └── config.toml         # theme and server settings
├── app.py                  # Streamlit UI (existing)
├── src/                    # embedder, preprocess, recommender, skill_gap (existing)
├── data/careers.csv        # curated career dataset (existing)
├── tests/
│   ├── test_recommender.py
│   └── test_app_smoke.py
├── evaluate.py             # Top-K accuracy harness (existing, now emits JSON)
├── requirements.txt        # pinned, CPU-only torch
├── Dockerfile
├── .dockerignore
├── .gitignore
└── README.md               # Spaces front matter + badges + how to run

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Run it locally and understand what it needs

A clean clone runs on your machine, and you know exactly which files, downloads and caches the app depends on — the things a hosting platform must also provide.

  1. Clone the repository into a fresh directory and open it in your editor. If the repository is private, cloning with the GitHub CLI handles authentication.
    bash
    gh repo clone prashanthaitha24/skillmatch-ai
    cd skillmatch-ai
    code .
  2. Create a virtual environment and install the dependencies. The first install pulls PyTorch, which is large; that is one of the things you will fix in the next phase.
    bash
    python3.12 -m venv .venv
    source .venv/bin/activate
    pip install --upgrade pip
    pip install -r requirements.txt
    Windows PowerShell: .venv\Scripts\Activate.ps1.
  3. Run the evaluation harness first. It loads the model (downloading about 90 MB from the Hugging Face Hub on first use) and reports Top-K accuracy on the hand-labelled cases; it is the fastest proof the pipeline works.
    bash
    python evaluate.py
    Check: It prints an accuracy figure and no traceback. A data/career_embeddings.npz cache file now exists (it is git-ignored).
  4. Start the Streamlit app and try the one-click example profiles.
    bash
    streamlit run app.py
    Check: The browser opens at http://localhost:8501, the spinner says it is loading the SBERT model, and a profile returns ranked careers with matched and missing skills.
  5. Write down the runtime dependencies you just observed, because a hosting platform has to satisfy each one: Python packages (including PyTorch), outbound access to the Hugging Face Hub to download the model on first run, a writable directory for the model cache and for data/career_embeddings.npz, and roughly 1 to 2 GB of RAM while the model is loaded.
    Hugging Face Spaces provides all four on its free CPU tier, which is why it is the first deployment target.
Phase 2

Make it reproducible and testable

Pinned dependencies that install quickly anywhere, a couple of tests, an evaluation script that can gate a pipeline, and Streamlit configuration in the repository.

  1. Create a branch for the deployment work.
    bash
    git switch -c feat/deploy
  2. Replace the loose >= requirements with pinned versions, and install the CPU-only PyTorch wheel: it is a fraction of the size of the default build and all the app needs. The extra index line is honoured by pip inside a requirements file.
    text
    --extra-index-url https://download.pytorch.org/whl/cpu
    torch==2.5.1+cpu
    
    sentence-transformers==3.3.1
    scikit-learn==1.6.1
    pandas==2.2.3
    numpy==2.2.1
    streamlit==1.41.1
    pypdf==5.1.0
    Save as requirements.txt. If a version is unavailable when you read this, pick the current stable release of each package and keep the +cpu suffix on torch. On Apple Silicon the +cpu wheel is not published; for local installs on a Mac use plain torch==2.5.1 and keep the CPU line for Linux hosts by using a second file requirements-linux.txt, or simply accept the default wheel locally.
  3. Reinstall from the pinned file in a fresh environment and confirm the app still works. This is the install the Space and the CI runner will perform.
    bash
    deactivate; rm -rf .venv
    python3.12 -m venv .venv && source .venv/bin/activate
    pip install -r requirements.txt
    python evaluate.py
    Check: Install time and download size drop noticeably; the evaluation result is unchanged.
  4. Add a small test module for the recommender: the top result for a clear data-science profile must be a data role, results must be ranked by similarity, and the skill gap must never list a skill the user already has.
    python
    # tests/test_recommender.py
    from pathlib import Path
    
    import pytest
    
    from src.recommender import CareerRecommender
    
    DATA = Path(__file__).resolve().parents[1] / "data" / "careers.csv"
    
    
    @pytest.fixture(scope="module")
    def rec():
        return CareerRecommender(DATA)   # loads the model once for the module
    
    
    def test_data_profile_ranks_a_data_role_first(rec):
        matches = rec.recommend("I know Python, SQL, machine learning, and data visualization.", top_k=3)
        assert "Data" in matches[0].career_title or "Analyst" in matches[0].career_title
    
    
    def test_results_are_sorted_by_similarity(rec):
        matches = rec.recommend("I build React frontends with JavaScript, HTML and CSS.", top_k=5)
        sims = [m.similarity for m in matches]
        assert sims == sorted(sims, reverse=True)
    
    
    def test_gap_excludes_skills_already_present(rec):
        matches = rec.recommend("Python, SQL, Docker, Kubernetes, Terraform", top_k=3)
        for m in matches:
            assert not set(s.lower() for s in m.matched_skills) & set(s.lower() for s in m.missing_skills)
    Adjust the method name (recommend) and attribute names to match src/recommender.py if they differ; the README's description of the match object is the reference.
  5. Add a smoke test that imports the Streamlit app in headless mode using Streamlit's testing API, so a broken import or a crash on load fails CI before anything is deployed.
    python
    # tests/test_app_smoke.py
    from streamlit.testing.v1 import AppTest
    
    
    def test_app_renders_without_exception():
        at = AppTest.from_file("app.py", default_timeout=120)
        at.run()
        assert not at.exception
        assert any("SkillMatch" in t.value for t in at.title)
  6. Install pytest into a dev requirements file and run everything once.
    bash
    printf -- '-r requirements.txt\npytest==8.3.4\n' > requirements-dev.txt
    pip install -r requirements-dev.txt
    pytest -q
    Check: Four tests pass. The first run is slow while the model loads; later runs use the cache.
  7. Make evaluate.py usable as a gate: print the Top-K accuracy as JSON and exit non-zero below a threshold. Append this to the end of the script (adapt the variable holding the accuracy to the script's own name).
    python
    import json
    import sys
    
    THRESHOLD = 0.8   # minimum acceptable Top-3 accuracy on the labelled cases
    
    
    def report(accuracy: float, k: int) -> None:
        result = {"top_k": k, "accuracy": round(accuracy, 3), "threshold": THRESHOLD}
        print(json.dumps(result))
        with open("eval_results.json", "w") as f:
            json.dump(result, f)
        if accuracy < THRESHOLD:
            print(f"FAIL: accuracy {accuracy:.3f} below threshold {THRESHOLD}", file=sys.stderr)
            sys.exit(1)
    Call report(accuracy, k) where the script currently prints its result. Now a change to the dataset or the model that degrades ranking quality fails the pipeline instead of shipping silently.
  8. Add a Streamlit configuration file so the app looks the same everywhere and never tries to open a browser or collect usage statistics on a server.
    toml
    [server]
    headless = true
    enableCORS = false
    enableXsrfProtection = true
    
    [browser]
    gatherUsageStats = false
    
    [theme]
    base = "dark"
    primaryColor = "#8b5cf6"
    Save as .streamlit/config.toml. Keep .streamlit/secrets.toml in .gitignore (it already is) even though this app needs no secrets.
  9. Commit the work.
    bash
    git add requirements.txt requirements-dev.txt tests .streamlit evaluate.py
    git commit -m "chore: pinned CPU deps, tests, evaluation gate, streamlit config"
Phase 3

Deploy to Hugging Face Spaces

A public URL running the app on the free CPU tier, built from a git push, with the GitHub repository staying private if you want it to.

  1. Create the Space in the browser: go to huggingface.co, sign in, click your avatar → New Space. Name it skillmatch-ai, choose the Streamlit SDK, hardware CPU basic (free), visibility Public, and create it. Note the URL it gives you: https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai.
    A Space is itself a git repository. You will push your code to it directly; the GitHub repository can remain private because the Space never reads from GitHub unless you tell it to.
  2. Create a Hugging Face access token with write permission: avatar → Settings → Access Tokens → Create new token → type Write. Copy it once; it is the password for git pushes to the Space.
    Treat it like any secret: never commit it. You will also store it as a GitHub Actions secret in the next phase.
  3. Add the Space's front matter to the top of README.md. Spaces read these fields to know how to run the app. Use the Streamlit version the Space creation page listed as supported (it shows the allowed range); keep it equal to the pin in requirements.txt if that version is allowed.
    text
    ---
    title: SkillMatch AI
    emoji: 🧭
    colorFrom: indigo
    colorTo: purple
    sdk: streamlit
    sdk_version: 1.41.1
    app_file: app.py
    pinned: false
    license: mit
    ---
    
    # 🧭 SkillMatch AI (existing README content continues below)
    app_file must point at the Streamlit entry point. If the Space rejects the sdk_version, change it to one from the supported list; the Space's build log tells you.
  4. Add the Space as a second git remote and push the branch to the Space's main. Git will ask for a username (your Hugging Face username) and a password (paste the write token).
    bash
    git remote add space https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai
    git push space feat/deploy:main --force
    The --force is only needed on this first push because the new Space already contains a placeholder commit. On macOS the token is stored in Keychain by the git credential helper after the first push; on Linux consider git config credential.helper store or use the token in the URL for a one-off.
  5. Watch the build: open the Space page and click the Logs tab (or the "Building" status). It installs requirements.txt, then starts Streamlit. The first start downloads the model from the Hub, which is fast because it stays inside Hugging Face's network.
    Check: The status turns to Running and the app loads at https://YOUR_HF_USER-skillmatch-ai.hf.space (also embedded on the Space page). Try an example profile.
  6. If the build fails, the log names the cause. The three usual ones: an unsupported sdk_version (change it to a listed one), a package version that does not exist for Linux (pin to one that does), or an import error from a missing file (make sure src/ and data/careers.csv were pushed; check .gitignore).
    Spaces run Linux on x86-64, so the torch==...+cpu wheel from the extra index works there even if you used the default wheel on a Mac.
  7. Confirm the model cache behaves: reload the page a few times. The recommender is wrapped in st.cache_resource, so the model loads once per process; subsequent visitors get instant results. Spaces on the free tier go to sleep after a period of inactivity and wake on the next visit with a cold start of about a minute — acceptable for a demo, and the Space settings show the sleep timer.
    Check: Second and later profile submissions return in well under a second.
  8. Merge the branch on GitHub through a pull request so main matches what is deployed.
    bash
    git push -u origin feat/deploy
    gh pr create --fill && gh pr merge --squash --delete-branch
    git switch main && git pull
Phase 4

Automate: test on pull requests, deploy on merge

Every pull request runs the tests and the evaluation gate; every merge to main updates the Space automatically, so the demo never drifts from the code.

  1. Store the Hugging Face token as a GitHub Actions secret and your Space id as a variable.
    bash
    gh secret set HF_TOKEN            # paste the write token when prompted
    gh variable set HF_SPACE --body "YOUR_HF_USER/skillmatch-ai"
  2. Create the CI workflow: install the pinned CPU dependencies with caching, run the tests, then run the evaluation gate and keep its JSON output as an artifact.
    yaml
    name: ci
    
    on:
      pull_request:
      push:
        branches: [main]
    
    permissions:
      contents: read
    
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: "3.12"
              cache: pip
          - run: pip install -r requirements-dev.txt
          - name: Cache the sentence-transformers model between runs
            uses: actions/cache@v4
            with:
              path: ~/.cache/huggingface
              key: hf-models-all-MiniLM-L6-v2
          - run: pytest -q
          - name: Evaluation gate (Top-K accuracy must clear the threshold)
            run: python evaluate.py
          - uses: actions/upload-artifact@v4
            with:
              name: eval-results
              path: eval_results.json
    Save as .github/workflows/ci.yml. Caching the model directory keeps CI runs to a couple of minutes.
  3. Create the deploy workflow: on every push to main, after CI succeeds, push the same commit to the Space. Pushing the full history with --force keeps the Space an exact mirror of main.
    yaml
    name: deploy
    
    on:
      workflow_run:
        workflows: [ci]
        types: [completed]
        branches: [main]
    
    permissions:
      contents: read
    
    jobs:
      push-to-space:
        if: github.event.workflow_run.conclusion == 'success'
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
              lfs: true
          - name: Push main to the Hugging Face Space
            env:
              HF_TOKEN: ${{ secrets.HF_TOKEN }}
              HF_SPACE: ${{ vars.HF_SPACE }}
            run: |
              git push --force "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}" HEAD:main
    Save as .github/workflows/deploy.yml. The username in the URL is ignored when a token is supplied; user is a placeholder. If you ever add files over 10 MB, track them with git LFS, which Spaces require for large files.
  4. Open a pull request with the workflows and watch CI run on it; then merge and watch the deploy workflow push to the Space and the Space rebuild.
    bash
    git switch -c ci/pipelines
    git add .github
    git commit -m "ci: tests and evaluation gate on PRs, deploy to Spaces on merge"
    git push -u origin ci/pipelines
    gh pr create --fill && gh pr checks --watch
    gh pr merge --squash --delete-branch
    git switch main && git pull && gh run watch
    Check: The Space's commit history (Files tab) shows your merge commit, and the app reflects any change you made.
  5. Prove the gate works: in a branch, remove three careers from data/careers.csv that the evaluation cases expect, open a pull request, and confirm CI fails on the evaluation step. Close the pull request without merging.
    Check: The Evaluation gate step is red with the accuracy and threshold in its output; the Space is untouched.
Phase 5

Containerise it

A Docker image anyone can run with one command, and the option to host the same image as a Docker-type Space or on any container platform.

  1. Create a branch and write a multi-stage Dockerfile. The build stage installs the CPU wheels; the final stage is slim, non-root, and pre-downloads the model so the container starts without network access to the Hub.
    dockerfile
    # syntax=docker/dockerfile:1
    FROM python:3.12-slim AS build
    WORKDIR /src
    COPY requirements.txt .
    RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt
    
    FROM python:3.12-slim
    RUN useradd --create-home --uid 10001 app
    WORKDIR /home/app
    COPY --from=build /venv /venv
    COPY app.py evaluate.py ./
    COPY src ./src
    COPY data ./data
    COPY .streamlit ./.streamlit
    ENV PATH="/venv/bin:$PATH" HF_HOME=/home/app/.cache/huggingface PYTHONUNBUFFERED=1
    # bake the model into the image so cold starts need no download
    RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" \
        && chown -R app:app /home/app
    USER app
    EXPOSE 8501
    HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health')"
    CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
  2. Add a .dockerignore so tests, caches and the virtual environment stay out of the image.
    text
    .git
    .venv
    .github
    tests
    __pycache__
    *.pyc
    .pytest_cache
    data/career_embeddings.npz
    eval_results.json
    .streamlit/secrets.toml
  3. Build and run the image, then open the app.
    bash
    docker build -t skillmatch-ai:local .
    docker run --rm -p 8501:8501 skillmatch-ai:local
    Check: http://localhost:8501 loads and returns results with the container's network disconnected (docker run --network none ... also works, proving the model is baked in).
  4. Optional: host the container on Hugging Face instead of the Streamlit runtime. Create a second Space with the Docker SDK, set its front matter to sdk: docker and app_port: 8501, and push the same repository. Docker Spaces are the route to custom system packages or to running the app on other platforms (Cloud Run, Fly.io, a VM) with the identical image.
    For a Docker Space the container must listen on the port named in app_port, which this Dockerfile does.
  5. Add the image build to CI so a broken Dockerfile is caught on pull requests. Append a job to ci.yml.
    yaml
      image:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: docker/setup-buildx-action@v3
          - uses: docker/build-push-action@v6
            with:
              context: .
              push: false
              tags: skillmatch-ai:ci
              cache-from: type=gha
              cache-to: type=gha,mode=max
    Indent it so image: sits at the same level as test: under jobs:.
  6. Commit and merge through a pull request; the deploy workflow updates the Space as before.
    bash
    git add Dockerfile .dockerignore .github/workflows/ci.yml
    git commit -m "build: multi-stage Docker image with the model baked in"
    git push -u origin HEAD
    gh pr create --fill && gh pr checks --watch && gh pr merge --squash --delete-branch
    git switch main && git pull
Phase 6

Evaluate, monitor and publish

Know whether the recommender is actually good, see whether anyone uses it, and make the project easy to find and run.

  1. Grow the evaluation set. Add at least twenty labelled profiles to evaluate.py (or move them to tests/eval_cases.json), including tricky ones: abbreviations (ml, k8s), profiles that fit two careers, and a profile with skills from no career. Rerun the harness and adjust the threshold to a level the current model clears with a small margin.
    Check: python evaluate.py prints the new accuracy; CI still passes; the threshold is written next to the number it was derived from.
  2. Add a second metric to the harness: mean reciprocal rank (1 divided by the position of the expected career), which rewards ranking the right answer first, not just within the top K. Report both in the JSON.
    python
    def mean_reciprocal_rank(ranked_titles: list[list[str]], expected: list[str]) -> float:
        total = 0.0
        for titles, want in zip(ranked_titles, expected):
            for i, t in enumerate(titles, start=1):
                if t == want:
                    total += 1 / i
                    break
        return total / len(expected)
  3. Add lightweight usage logging inside the app: one structured log line per recommendation request with the length of the input, the number of results and the top similarity — never the user's text itself. Streamlit's process logs are visible in the Space's Logs tab.
    python
    import json
    import logging
    import time
    
    log = logging.getLogger("skillmatch")
    
    
    def log_request(profile_text: str, matches, started: float) -> None:
        log.info(json.dumps({
            "event": "recommend",
            "input_chars": len(profile_text),
            "results": len(matches),
            "top_similarity": round(float(matches[0].similarity), 3) if matches else None,
            "latency_ms": int((time.time() - started) * 1000),
        }))
    Call it where the app computes recommendations, wrapping the call with started = time.time(). Logging inputs would make the demo a store of other people's résumés; the counts and latency are all you need to see usage and performance.
  4. Use the Space's built-in analytics (the Space page shows views and likes) and its Logs tab for the request lines above. If you want persistence, write the same JSON lines to a Hugging Face dataset repository with the huggingface_hub library; for a demo the logs tab is enough.
    Free Spaces have no uptime guarantee; if the demo matters for a job application, consider the paid always-on setting for the week it counts.
  5. Update the README: an "Open in Spaces" badge at the top, the one-line local run, the Docker run, how to run the tests and the evaluation, and the accuracy and MRR figures with the date they were measured. Link to the lesson on RAG and evals in the AI track for readers who want the theory.
    text
    [![Open in Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Open%20in%20Spaces-blue)](https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai)
    
    ## Try it
    
    Live demo: https://huggingface.co/spaces/YOUR_HF_USER/skillmatch-ai
    
    ## Run locally
    
        python3.12 -m venv .venv && source .venv/bin/activate
        pip install -r requirements.txt
        streamlit run app.py
    
    ## Run with Docker
    
        docker build -t skillmatch-ai .
        docker run --rm -p 8501:8501 skillmatch-ai
    
    ## Tests and evaluation
    
        pip install -r requirements-dev.txt
        pytest -q
        python evaluate.py     # Top-3 accuracy 0.92, MRR 0.85 on 24 labelled cases (measured 2026-09-21)
    Replace the figures with your own from eval_results.json.
  6. Decide on the GitHub repository's visibility. The Space is public either way. Making the GitHub repository public lets people read the code and the CI setup, which is the point if this is a portfolio piece: Settings → General → Danger Zone → Change visibility. If you keep it private, add a line to the README on the Space saying the source is available on request.
    Check: Merge the README through a pull request; the Space redeploys and shows the badge and the demo link at the top of its page.
Help

Troubleshooting

The Space build fails with a message about sdk_version not being supported
Open the Space's Settings or the creation form to see the allowed Streamlit versions and set sdk_version in the README front matter to one of them; pin streamlit in requirements.txt to the same version.
pip on the Space cannot find torch==2.5.1+cpu
Make sure the --extra-index-url https://download.pytorch.org/whl/cpu line is the first line of requirements.txt, and that the torch version exists on that index; if not, use the newest version listed there.
The app starts but shows a traceback about a missing data/careers.csv or src module
The files were not pushed. Run git ls-files and check .gitignore; the dataset must be committed, only the .npz cache is ignored.
The first request on the Space takes over a minute or times out
That is the model download on first start plus embedding the careers. It happens once per container start; keep st.cache_resource on the recommender. For instant cold starts use the Docker Space with the model baked into the image.
The deploy workflow fails with authentication failed when pushing to the Space
The token must be a write token and stored as the HF_TOKEN secret; regenerate it if in doubt. Check that HF_SPACE is user/space-name, not the full URL.
Pushing to the Space is rejected because a file is larger than 10 MB
Spaces require git LFS for large files: git lfs install, git lfs track "*.npz" (or the file type), commit .gitattributes, and push again. Better: do not commit generated caches at all.
AppTest smoke test times out in CI
Model loading exceeds the default timeout on a cold runner. The test sets default_timeout=120; make sure the model cache step in ci.yml is before pytest, and increase the timeout if the runner is slow.
The evaluation gate fails after adding new careers to the dataset
New careers change the ranking of existing cases. Inspect which cases now fail, decide whether the new ranking is actually better (then update the expected label) or worse (then fix the career description), and keep the threshold honest.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →