|
Download README.md from codegood/job-chat: direct link, hf CLI and curl.
- Browser
- Download file 9.68 kB
-
https://huggingface.co/spaces/codegood/job-chat/resolve/main/README.md
- Command line
-
hf download hf://spaces/codegood/job-chat/README.md
-
curl -L -o README.md https://huggingface.co/spaces/codegood/job-chat/resolve/main/README.md
9.68 kB
| 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. | |