A locally-hosted, RAG-driven job search platform built in three connected stages: a web search agent, a RAG-based ATS resume checker with redlining, and a live job-posting matcher. We write and own the retrieval, scoring, and matching logic directly instead of delegating it to an agent's context window.
Built to be used for the author's own job search. Calls free-tier APIs when local inference would blow the 4 GB VRAM budget.
Current features: Fresher Mode, employment-type classification, LinkedIn integration, background pipeline maintenance, Tesseract OCR for embedded images, post age grading (fresh/stale/re-stamped/ghost badges), pipeline analytics (Kanban board + stats), cover letter + follow-up email drafting, application tracker with notification bell, and a unified minimalist UI with tactical dark mode.
curl -sSL https://raw.githubusercontent.com/anubhavsanket/wayfarer/main/setup.sh | bashOr clone manually:
git clone https://github.com/anubhavsanket/wayfarer.git
cd wayfarer
bash setup.shThe script clones the repo, creates .env, pulls Docker images, starts all 5 services,
and pulls the embedding model. Takes about 2 minutes on first run.
After setup, open http://localhost:3000 and go to the Settings tab to enter
your API keys, or edit .env directly.
- What Wayfarer does
- Architecture
- Quick start
- Local development
- Configuration
- API reference
- The job board registry
- Testing
- Release highlights
- Known limitations
| Stage | What it answers | Key endpoint |
|---|---|---|
| 1. Web Search Agent | "Search the web for X and synthesize an answer with sources." | POST /api/v1/search |
| 2. ATS Resume Checker | "Check my resume against this JD, tell me exactly what to change and why." | POST /api/v1/resume/check |
| 3. Job Matcher | "Show me live postings ranked by fit, with apply links." | GET /api/v1/jobs/match |
Every output keeps you in the loop. Wayfarer evaluates, ranks, and drafts. You submit applications, fill forms, and click through to third-party sites yourself.
- Owned RAG pipeline — embeddings, vector store, and hybrid scoring run in-process. The retrieval code is hand-written rather than scaffolded by an agent.
- Honest substitution — each keyword suggestion in Stage 2 traces to a real resume bullet. Gaps get flagged, never silently filled.
- Confidence-tiered redlines —
Verified/Reworded/Gap. TheGaptier shows up in the output, never dropped. - Multi-provider inference — rate-limit-aware router with NVIDIA NIM → OpenRouter → local Ollama fallback. Free tiers only.
- Post age grading —
fresh/stale/re-stamped/ghostbadges on JobMatch cards using SQLitepost_historypersistence (first-seen tracking, content-hash comparison, open-jobs pattern). - Job board registry —
config/job_boards.yamldrives Stage 3 discovery (bluedoor REST, LinkedIn HTML guest API). - Fresher Mode — filter postings to entry-level/junior roles using a small local LLM (qwen3:0.6b) for experience-level classification.
- Posting age grading —
fresh/stale/re-stamped/ghostbadges on JobMatch cards usingpost_historypersistence (first-seen tracking, content hash comparison). Integrated with open-jobs pattern. - Pipeline analytics — Kanban dashboard (
/tracker) with conversion funnel stats (interview rate, avg match score, days-in-stage, source breakdown, notification bell for new/updated applications). - Cover letter + follow-up — Stage-aware draft generation with tone control, using ResumeGraph for token-efficient, grounded prompts.
- Application tracker — Save/apply tracking with pipeline status updates, notes, resume linkage, followed by cover-letter and follow-up drafts.
- Tesseract OCR — extracts text from embedded images in DOCX resumes (profile photos, diagrams, infographics) so the checker reads parts a plain parser would miss.
- Unified UI aesthetic — clean, minimalist interface, tactical dark mode, animated score bars, sticker badges, anime.js entrance animations.
┌─────────────────────────────┐
│ LLM Router │
│ NVIDIA NIM ⇄ OpenRouter │
│ rate-limit-aware fallback │
└───────────────┬───────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
┌───────▼────────┐ ┌───────────▼──────────┐ ┌───────────▼──────────┐
│ Stage 1 │ │ Stage 2 │ │ Stage 3 │
│ Search Agent │ │ Resume/ATS Checker │ │ Job Matcher │
│ │ │ │ │ │
│ Search API │ │ Unstructured + │ │ bluedoor + LinkedIn │
│ (Tavily/Brave) │ │ pdfplumber fallback │ │ Fresher Mode │
│ + Crawl4AI │ │ Tesseract OCR │ │ Hybrid scoring │
│ fetch/clean │ │ Confidence-tiered │ │ Legitimacy checks │
│ │ │ redline generator │ │ Background refresh │
└─────────────────┘ └────────────────────────┘ └───────────────────────┘
│ │ │
└───────────────────────────┴───────────────────────────┘
│
┌───────────▼───────────┐
│ Qdrant │
│ search_cache │
│ resume_sections │
│ job_postings │
└─────────────────────────┘
Shared infrastructure (build once, used in all 3 stages):
- LLM Router —
backend/app/llm_router.py. Every stage calls inference through this module, never directly against a provider. Supports 5 providers: NVIDIA NIM, OpenRouter, Ollama (local), LM Studio (local), and any custom OpenAI-compatible endpoint. Tracks per-provider rate limits and falls back automatically. Sanitizes multimodal payloads for text-only models. - Embedding layer —
nomic-embed-textvia Ollama, kept local. Embeddings are cheap enough on a GTX 1650 that they don't need router fallback. - Qdrant — one persistent store, three separate collections (
search_cache,resume_sections,job_postings). Sharing the embedding space enables Stage 2 ↔ Stage 3 reuse. - Confidence scoring —
backend/app/core/confidence.py. TheVerified / Reworded / Gapclassifier from the real-estate RAG project. Both Stage 2 and Stage 3 call the samematch_keyword_to_bulletfunction. - Resume graph —
backend/app/core/resume_graph.py. Graph-based structured resume memory for token-efficient per-posting matching. - RAG engine —
backend/app/core/rag_engine.py. LlamaIndex-based RAG pipeline with Qdrant vector store integration.
wayfarer/
├── backend/ # FastAPI service
│ ├── app/
│ │ ├── main.py # FastAPI entry, health, lifespan
│ │ ├── config.py # Pydantic Settings (.env-driven)
│ │ ├── context.py # Per-request ContextVar for header overrides
│ │ ├── llm_router.py # Multi-provider inference router
│ │ ├── exceptions.py # Error handling, validation helpers
│ │ ├── vector_store.py # Multi-collection Qdrant wrapper
│ │ ├── routers/
│ │ │ ├── health.py # GET /health
│ │ │ ├── stage1.py # POST /api/v1/search
│ │ │ ├── stage2.py # POST /api/v1/resume/check, save
│ │ │ └── stage3.py # POST /api/v1/jobs/match, refresh
│ │ ├── models/
│ │ │ ├── schemas.py # Pydantic request/response models
│ │ │ └── job_boards.py # Board registry models + connector
│ │ ├── core/
│ │ │ ├── confidence.py # Tier classifier + match_keyword_to_bullet
│ │ │ ├── resume_graph.py # Graph-based structured resume memory
│ │ │ └── rag_engine.py # LlamaIndex RAG pipeline over Qdrant
│ │ ├── services/
│ │ │ ├── search_api.py # Tavily + Brave clients
│ │ │ ├── web_fetch.py # Crawl4AI concurrency-capped fetcher
│ │ │ ├── search_service.py # Stage 1 orchestrator
│ │ │ ├── resume_parser.py # PDF/DOCX parsing + Tesseract OCR + ATS sim
│ │ │ ├── ats_checker.py # Stage 2 orchestrator
│ │ │ ├── resume_saver.py # Save with/without overwrite
│ │ │ ├── resume_store.py # Upload persistence
│ │ │ ├── job_matcher.py # Stage 3 orchestrator + Fresher Mode + age grading
│ │ │ ├── grading.py # Posting age grade (fresh/stale/re-stamped/ghost)
│ │ │ ├── cover_letter.py # Cover letter draft with ResumeGraph
│ │ │ ├── follow_up.py # Stage-aware follow-up email draft
│ │ │ ├── jobs_queue.py # Redis-backed background refresh queue
│ │ │ └── legitimacy.py # Ghost / no-sponsorship checks
│ │ └── utils/
│ │ └── cache.py # Content-hash memoization
│ ├── tests/ # 91 tests (unit + integration)
│ ├── requirements.txt
│ ├── pytest.ini
│ └── Dockerfile
├── frontend/ # React + Vite + TypeScript + Tailwind
│ ├── src/
│ │ ├── App.tsx # Tabbed UI shell (Search/Resume/Jobs/Settings)
│ │ ├── pages/
│ │ │ ├── Search.tsx # Stage 1 UI
│ │ │ ├── ResumeCheck.tsx # Stage 2 UI
│ │ │ ├── JobMatch.tsx # Stage 3 UI + Fresher Mode + age-grade badges
│ │ │ ├── Tracker.tsx # Pipeline analytics (Kanban + stats)
│ │ │ └── Settings.tsx # API keys + resume upload
│ │ ├── components/
│ │ │ ├── ui/ # Button, Card, badge (Sticker), progress (ScoreBar)
│ │ │ ├── Reveal.tsx # Anime.js entrance animations
│ │ │ └── LoadingIndicator.tsx # Animated loading dots
│ │ ├── stores/
│ │ │ ├── settings.ts # localStorage-backed API key store
│ │ │ └── theme.ts # Dark/light theme hook
│ │ ├── lib/
│ │ │ ├── api.ts # Typed API client
│ │ │ ├── types.ts # Shared TypeScript types
│ │ │ └── animations.ts # Anime.js animation helpers
│ │ ├── styles/
│ │ │ ├── globals.css # Unified minimalist design tokens
│ │ │ └── legacy-neo-brutalist.css # Legacy design system backup
│ │ └── test/setup.ts # Vitest setup with matchMedia mock
│ ├── vite.config.ts # Vite + Vitest config
│ ├── tailwind.config.js # Custom palette + shadow system
│ ├── Dockerfile + nginx.conf
│ └── package.json
├── config/
│ ├── settings.yaml # Main config (informational)
│ └── job_boards.yaml # Stage 3 board registry
├── docker-compose.yml # 5-service stack
├── setup.sh # One-command setup
├── .env.example # Copy to .env, fill in keys
├── benchmark.py # Optional performance benchmark
└── README.md
- Docker + Docker Compose (v2+)
- An NVIDIA GPU is optional but recommended (router falls back to API inference without one)
git clone https://github.com/anubhavsanket/wayfarer.git
cd wayfarer
cp .env.example .env
# edit .env and fill in at least ONE of:
# NVIDIA_NIM_API_KEY / OPENROUTER_API_KEY
# TAVILY_API_KEY (for Stage 1 search)docker compose up --buildThis starts five services:
| Service | Port | Purpose |
|---|---|---|
api |
8000 | FastAPI backend |
qdrant |
6333/6334 | Vector store (REST / gRPC) |
ollama |
11434 | Local inference + embeddings |
redis |
6379 | Background job queue |
frontend |
3000 | React UI served by nginx |
# Embedding model (required for all stages)
docker compose exec ollama ollama pull nomic-embed-text
# Chat model (only if using Ollama for inference)
docker compose exec ollama ollama pull llama3.2:3b
# Fresher Mode classifier (optional, only needed for experience-level filtering)
docker compose exec ollama ollama pull qwen3:0.6bcurl http://localhost:8000/health
# → 200 OK with per-dependency status- Open http://localhost:3000
- Go to Settings tab — enter your API keys and upload your main resume
- Go to Search tab — ask any question
- Go to Resume Check tab — paste a JD to get ATS analysis (resume is already loaded)
- Go to Job Match tab — see live postings ranked by fit (uses your resume automatically)
The resume you upload in Settings carries across all stages, so you upload it once.
- API docs: http://localhost:8000/docs — interactive Swagger UI
- Python 3.11 or 3.12 (3.14 doesn't build the pinned dependencies)
- Node.js 20+ (only for frontend work)
- Ollama installed locally
- Tesseract OCR (for embedded image extraction from DOCX resumes)
cd backend
python3.11 -m venv .venv
source .venv/bin/activate # Linux/Mac
# or .venv\Scripts\activate # Windows
pip install -r requirements.txt# backend/.env
OLLAMA_ENDPOINT=http://localhost:11434
QDRANT_HOST=localhost
QDRANT_PORT=6333
REDIS_URL=redis://localhost:6379
LLM_PROVIDER=ollamaollama pull nomic-embed-text
# For local LLM inference:
ollama pull llama3.2:3bFrom the project root:
uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000Qdrant falls back to a persistent local store at ./qdrant_data/ automatically.
cd frontend
npm install
npm run dev # → http://localhost:3000 (proxies /api to :8000)Configuration is layered:
.env— runtime overrides (API keys, hostnames). Highest priority.- Settings tab in the frontend — stores keys in localStorage, sends as request headers. No keys in git.
config/job_boards.yaml— Stage 3 board registry (add a board = config change).
| Provider | .env setting |
API key needed? | Notes |
|---|---|---|---|
ollama |
LLM_PROVIDER=ollama |
No | Local, pull model first. Default provider. |
nvidia |
LLM_PROVIDER=nvidia |
NVIDIA_NIM_API_KEY |
Free tier; model IDs must come from the live catalog (older models are EOL'd) |
openrouter |
LLM_PROVIDER=openrouter |
OPENROUTER_API_KEY |
Free tier, wide model selection |
lmstudio |
LLM_PROVIDER=lmstudio |
No | Set LMSTUDIO_ENDPOINT + LMSTUDIO_MODEL |
custom |
LLM_PROVIDER=custom |
CUSTOM_LLM_API_KEY |
Any OpenAI-compatible endpoint |
The router falls back through the provider list whenever the primary fails. Even an explicitly selected provider falls through the chain instead of erroring out.
config/job_boards.yaml drives Stage 3 discovery. Each board declares the search
query parameter name it expects (q for bluedoor, keywords for LinkedIn), so no
per-board code is needed to route resume-derived keywords to the right provider.
{"status":"ok","dependencies":[{"name":"qdrant","status":"up"},...]}curl -X POST http://localhost:8000/api/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "best practices for RAG?", "max_sources": 3}'{
"answer": "Based on the provided sources...",
"citations": [{"id":1, "url":"...", "title":"...", "snippet":"..."}],
"sub_queries_used": ["best practices for RAG"],
"cached": false
}curl -X POST http://localhost:8000/api/v1/resume/check \
-F "resume_file=@my_resume.pdf" \
-F "jd_text=Looking for an ML engineer with Python, PyTorch, and AWS."{
"resume_id": "abc123",
"ats_score": 0.85,
"structural_issues": [{"location": "table on page 1", "issue": "..."}],
"keyword_gaps": [
{"keyword": "pytorch", "tier": "reworded", "confidence": 0.84, "rationale": "..."}
]
}Default mode is non-destructive (writes a new file). Overwrite requires confirm_overwrite: true.
| Param | Type | Default | Notes |
|---|---|---|---|
resume_id |
string | required | from /resume/check response |
limit |
int | 20 | max results |
location_mode |
enum | specific_city |
specific_city / remote_only / hybrid / open_to_relocation |
cities |
csv | "" |
comma-separated cities |
remote_ok |
bool | false | include remote postings |
fresher_only |
bool | false | filter to fresher/junior roles only |
test |
bool | false | return sample data |
curl "http://localhost:8000/api/v1/jobs/match?resume_id=abc123&fresher_only=true&limit=10"Re-fetches from all enabled boards and stores in Qdrant.
Each job source is defined in config/job_boards.yaml. You add a board by
adding a YAML entry, no code changes needed. Each board can specify the search query
parameter name it expects via query_param (e.g. q for bluedoor, keywords
for LinkedIn).
- name: bluedoor
enabled: true
type: rest_api
base_url: "https://api.bluedoor.sh/job-postings/v1/jobs/search"
auth: api_key
api_key_env: "BLUEDOOR_API_KEY"
rate_limit_per_min: 100
query_param: "q"
field_mapping:
title: "$.title"
company: "$.org_name"
location: "$.city"
url: "$.apply_url"
pagination:
type: "none"
param: ""
max_pages: 1# Unit tests (needs Python 3.11 venv, no Docker required)
cd backend
source .venv/bin/activate # or .venv\Scripts\activate on Windows
python -m pytest tests/test_stage1.py tests/test_stage2.py tests/test_stage3.py -v
# E2E tests (requires all Docker services running)
python -m pytest tests/test_docker_e2e.py -vTest suite: 91 backend tests + 11 frontend tests, all passing.
| Feature | Status | Description |
|---|---|---|
| Fresher Mode | ✅ | Filter postings to entry-level/junior roles via qwen3:0.6b classification |
| Employment type | ✅ | full_time/contract/freelance/part_time field on JobMatch |
| LinkedIn integration | ✅ | HTML parsing of guest API (50+ Indian job postings) |
| Background refresh | ✅ | POST /api/v1/jobs/refresh — dedup, normalize, stale-drop |
| Pipeline analytics / Tracker | ✅ | Kanban board (/tracker) + stats (interview rate, avg score, conversion funnel, source breakdown, notification bell). |
| Structured resume memory | ✅ | Graph-based entity extraction for token-efficient matching |
| Settings dashboard | ✅ | API keys stored in localStorage, not in git |
| LM Studio / custom | ✅ | Any OpenAI-compatible endpoint works |
| Side-by-side redlines | ✅ | Original vs. suggested view in Resume Check |
| One-command setup | ✅ | bash setup.sh or curl link |
| Tesseract OCR | ✅ | Extract text from embedded images in DOCX resumes |
| Unified UI | ✅ | Clean, unified, minimalist interface + tactical dark mode |
| Stored resume | ✅ | Upload once in Settings, used across all stages |
| OOXML track-changes | ✅ | Resume save outputs Word-compatible tracked changes |
| Background refresh queue | ✅ | Redis-backed job queue for async board refresh |
| Per-request overrides | ✅ | Frontend settings sent as X-* headers per-request |
| Qdrant vector store | ✅ | Migrated from ChromaDB; three collections shared across stages |
| Targeted job search | ✅ | Resume-derived keywords routed per-board (q / keywords) |
| Board interleaving | ✅ | Results balanced across providers, title+company embed fallback for empty JDs |
| Provider fallback chain | ✅ | Explicitly-selected providers still fall through the router chain |
| PDF resume parsing | ✅ | pdfplumber fallback fixes 500 on PDF uploads |
| Location-aware matching | ✅ | Frontend location filters now sent to the job matcher API |
bluedoor.shdescriptions — the API doesn't return JD descriptions in search results. The matcher falls back to embedding"<title> at <company>", so titles still rank accurately even though experience classification is less precise.- LinkedIn guest API — returns HTML (parsed via regex), not structured JSON. Keep volume low, personal-use only.
- NVIDIA model IDs — free-tier models are frequently EOL'd.
config.pyships with live catalog IDs, but they may need regenerating each release cycle. - VRAM ceiling — keep embeddings + local LLM under 4 GB total. Heavy inference is routed to APIs via the LLM Router.
- OOXML track-changes — supported for resume save output. The UI redline view is HTML-based.
- Tesseract OCR accuracy — low-resolution embedded images may produce noisy text. Icons under 80px are skipped.
- Python 3.11 / 3.12 only — pinned dependencies don't build on 3.14.
- GPU requirement — the
ollamacompose service reserves an NVIDIA GPU viadeploy.reservations. It runs CPU-only with degraded performance if no GPU is present.
Proprietary. Built for the author's personal job search automation and as an AI engineering portfolio piece.
