File size: 9,683 Bytes
627adf5 1ed84a8 627adf5 532111b 87149fa 532111b 87149fa 532111b 9d8bbd6 532111b d7d27a3 532111b 87149fa 532111b d7d27a3 532111b e287216 532111b d7d27a3 87149fa 532111b 87149fa d7d27a3 532111b 87149fa 532111b 9d8bbd6 532111b 9d8bbd6 532111b d7d27a3 87149fa d7d27a3 532111b 9d8bbd6 e287216 532111b be97847 87149fa 532111b 9d8bbd6 532111b 87149fa 532111b 87149fa 532111b 9d8bbd6 532111b be97847 532111b 87149fa be97847 87149fa be97847 87149fa 532111b be97847 532111b 87149fa 532111b be97847 532111b 87149fa be97847 87149fa be97847 87149fa d7d27a3 be97847 87149fa 532111b 87149fa 532111b 87149fa 532111b 87149fa 9d8bbd6 87149fa 532111b d7d27a3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | ---
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<br/>full profile, structured output"]
DECIDE -->|review, cover_letter, email| SKILL["skill_*<br/>skill file + full profile + job description"]
DECIDE -->|no action| ROUTER{{semantic router}}
ROUTER -->|review / ats / cover_letter / email| SKILL
ROUTER -->|trivial| TRIVIAL["trivial_answer<br/>no profile"]
ROUTER -->|specific| RAG["rag_answer<br/>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.
|