---
title: Job Chat
emoji: 💬
colorFrom: blue
colorTo: red
sdk: gradio
sdk_version: "6.0.0"
app_file: app.py
pinned: false
---
# job-chat
A private assistant for job applications. It answers questions about your background, drafts
interview answers, scores your profile against a job description, and writes cover letters —
grounded only in your own career documents.
## Setup
**1. Create the environment**
Python 3.13 is what CI runs on; 3.11+ works.
```bash
python -m venv .venv
```
Activate it — Windows:
```powershell
.venv\Scripts\activate
```
macOS / Linux:
```bash
source .venv/bin/activate
```
Then:
```bash
pip install -r requirements.txt
```
**2. Configure `.env`** in the project root (optional if you will paste your key into the UI):
```ini
OPENROUTER_API_KEY=... # required to use the model; can also be entered in the UI
CANDIDATE_NAME=Your Name # used in the system prompt
# Optional — Langfuse tracing. Tracing turns on only when both keys are present.
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://us.cloud.langfuse.com
```
| Variable | Default | Purpose |
|---|---|---|
| `OPENROUTER_API_KEY` | — | Required to use the model. Set it here or paste a key into the always-visible UI field. |
| `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
| `OPENROUTER_MODEL` | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | OpenRouter model id. |
| `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
| `LLM_MAX_TOKENS` | `20000` | Response cap for ordinary chat. See the token budget note below. |
| `SKILL_MAX_TOKENS` | `6500` | Response cap for prose skills (review, cover letter, email). |
| `ATS_MAX_TOKENS` | `12000` | Response cap for ATS structured output. Increase only if the selected model supports it. |
| `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
| `MAX_HISTORY_TURNS` | `10` | Turns replayed to the model per request. |
| `EMBEDDING_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Routing and retrieval embeddings, run through fastembed (ONNX, no torch). Downloaded on first run. |
| `RETRIEVAL_TOP_K` | `10` | Excerpts retrieved for a narrow question. |
| `CHECKPOINT_DB` | `checkpoints.sqlite` | Where conversation threads persist. |
| `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | — | Enable tracing when both are set. |
| `LANGFUSE_HOST` (alias: `LANGFUSE_BASE_URL`) | `https://cloud.langfuse.com` | Langfuse region. Use `https://us.cloud.langfuse.com` if your keys are US. |
**3. Run**
```bash
python app.py # http://localhost:7860
```
If you did not set `OPENROUTER_API_KEY` in `.env`, paste it into the **OpenRouter API key** field and select **Connect key**. An API key is required to use the model. A key entered in the UI stays in server memory for that browser session and is not written to disk. Only enter a key when you trust the server running this app.
Upload one or more PDF, DOCX, Markdown, or text files in **Your profile**. The app converts them to Markdown in memory and loads them into the profile pipeline; a later upload replaces the current profile. For PDFs, RapidOCR with ONNX Runtime is used as a fallback if MarkItDown fails or extracts no text, including for scanned/image-only pages. Uploads are not written to the project `data/` directory. Add a job description in **Target role** before chatting or using a skill button; requests without one do not call the model.
Select
**Stop** beside the message box to cancel a response that's being generated; any text already
received remains in the conversation.
## Project structure
```
job-chat/
├── app.py # Gradio UI and event wiring — the entry point
├── graph.py # LangGraph state graph, routing, LLM construction
├── prompts.py # Chat and skill system prompts
├── skills.py # Skill registry: buttons, labels, loader
├── skills/ # One markdown file per skill; edit these to change behaviour
├── router.py # Semantic router: trivial / specific / full / the four skills
├── retrieval.py # Chunking and similarity search over the profile
├── embeddings.py # fastembed wrapper
├── ats.py # ATS schema and score calculation
├── dataloader.py # Converts uploads and formats Markdown profile documents
├── config.py # Environment settings with validation
├── observability.py # Langfuse tracing (optional, fails open)
├── tests/ # Unit tests; no API calls
├── .github/workflows/tests.yml # CI: runs the suite on push and pull request
├── requirements.txt # Runtime
└── requirements-dev.txt # Tests (install on top of requirements.txt)
```
Uploaded profile documents are held in server memory for the active Gradio session. Only upload
personal documents to a deployment you trust. Hugging Face may also process temporary upload
files as part of serving the app; consult the Space's privacy and storage settings.
## Graph structure
A skill button routes on its `action` straight to that skill. Free-form chat is classified by a
semantic router, which either picks a skill (when the request clearly asks for one) or decides how
much of your profile the question actually needs.
```mermaid
flowchart TD
START([START]) --> DECIDE{action set?}
DECIDE -->|ats| ATS["ats_analysis
full profile, structured output"]
DECIDE -->|review, cover_letter, email| SKILL["skill_*
skill file + full profile + job description"]
DECIDE -->|no action| ROUTER{{semantic router}}
ROUTER -->|review / ats / cover_letter / email| SKILL
ROUTER -->|trivial| TRIVIAL["trivial_answer
no profile"]
ROUTER -->|specific| RAG["rag_answer
retrieved excerpts"]
ROUTER -->|full / uncertain| ANSWER
ATS --> FINISH([END])
SKILL --> FINISH
ANSWER --> FINISH
TRIVIAL --> FINISH
RAG --> FINISH
```
**State**
| Field | Purpose |
|---|---|
| `messages` | Conversation, appended via `add_messages`. |
| `job_description` | The pasted target role. |
| `action` | `review`, `ats`, `cover_letter`, `email`, or empty for free chat. |
**Nodes**
| Node | Context sent | Used for |
|---|---|---|
| `answer` | Full profile + job description | Any broad question |
| `skill_review`, `skill_cover_letter`, `skill_email` | Skill file + full profile + job description | The matching button, or a chat request the router recognises. Output capped at `SKILL_MAX_TOKENS`. |
| `rag_answer` | Top `RETRIEVAL_TOP_K` excerpts | Narrow factual questions |
| `trivial_answer` | None | Greetings and pleasantries |
| `ats_analysis` | `analyze-ats-keywords.md` + full profile + job description | The ATS button, or a chat request the router recognises |
All three chat nodes replay only the last `MAX_HISTORY_TURNS` turns. Older turns stay in the
checkpoint but are not resent, so cost per turn stays flat however long the conversation runs.
`ats_analysis` asks the model only to classify each job-description keyword as matched or missing.
The percentage is computed in Python from the list lengths, so the score cannot contradict the
lists it is derived from.
**Retrieval.** Uploaded profile documents are split into 100-word windows with 15 words of
overlap, keeping adjacent context available to narrow searches. The `rag_answer` index combines
BM25 keyword rankings with FAISS cosine-similarity rankings over FastEmbed vectors, then merges
the results with reciprocal-rank fusion. This hybrid index is rebuilt for each uploaded profile.
**Routing rules.** Every skill button bypasses the router and receives the full profile: a
retrieval miss inside an ATS score or a resume review would silently drop real evidence. For free
chat, the router compares the question against the centroid of reference examples per route
(`router.py`) and falls back to the full profile whenever the best match is weak or close to the
runner-up. The asymmetry is deliberate — routing a greeting to the full profile only wastes
tokens, while routing a real question away from it produces a confident answer with no evidence
behind it.
**Measured cost per request** (this profile, one narrow question):
| Route | Approximate prompt tokens | Saving |
|---|---|---|
| `answer` | 4,110 | — |
| `rag_answer` | 1,028 | 75% |
| `trivial_answer` | 195 | 95% |
**Persistence.** A SQLite checkpointer keeps each thread on disk, so conversations survive a
restart. "Reset conversation" starts a new thread; the previous one is retained.
## Tests
```bash
pip install -r requirements-dev.txt # on top of requirements.txt
python -m pytest tests/ -q
```
Unit tests only — no API calls, no keys needed. They cover the ATS score arithmetic, config
validation, document loading, chunking and retrieval, semantic routing, node selection, history
trimming, temperature selection, and prompt templating. `tests/test_isolation.py` asserts the
suite never touches your real credentials or profile documents. CI runs the same command on
every push to `main` and every pull request.
## Token budget
`LLM_MAX_TOKENS` defaults to 20,000 to cap ordinary chat responses. ATS uses its own
12,000-token cap because it returns a structured classification of every job-description
keyword. OpenRouter model output limits vary; if ATS reports a truncated response, choose a
model with a larger output limit or lower `ATS_MAX_TOKENS` to fit the selected model.