Job hunting as a new grad is a full-time job by itself. You sift through hundreds of postings every week to find a handful worth applying to. You click "Easy Apply" until your eyes hurt. You write the same cover letter forty times. By month two of a search, you're applying to roles you wouldn't take, in industries you don't care about, because at that point the cost of thinking about each listing is higher than the cost of submitting to one.
Watch the short tour: drop a resume, watch the queries stream, read the per-job reasoning.
A run has three steps.
(resume, job) pair and writes a five-dimension fit score:Figure 1. End-to-end steps of the framework.
What you get back isn't a list of fifty roles. It's a small shortlist with defensible reasoning. You can read why the model thinks the second-ranked job beats the third.
The teacher is DeepSeek V4 Pro. Strong at structured reasoning, willing to follow a strict output schema, cheap enough to run once over a large corpus offline. It is used as a label generator, not as an inference-time dependency.
The student is Qwen3-8B. Small enough to fit on a single ZeroGPU slice once quantized to Q4_K_M, large enough to absorb the teacher's structured judgement.
The corpus came from a closed loop, resume-aware end-to-end:
(resume, job) pair across the same five dimensions used at inference, with one sentence of reasoning per dimension.Everything ships in four foreign-key-clean configs at build-small-hackathon/job-search-distill.
Two LoRA SFT runs on a single A100 via Modal, one per task:
build-small-hackathon/job-searcher-qwen3-8B, and a Q4_K_M base plus LoRA-GGUF sidecars at build-small-hackathon/job-searcher-qwen3-8B-gguf for the llama.cpp serving path.LoraConfig(
r=16,
lora_alpha=16,
task_type="CAUSAL_LM",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
The Space runs llama-cpp-python with the pre-built CUDA wheel on a HuggingFace ZeroGPU Space. Two design choices that matter:
Llama inside @spaces.GPU. ZeroGPU recycles the CUDA context per call, so a module-level instance would hold a dead context on the second use.@spaces.GPU call. The model loads once and yields events for every job, instead of paying a fresh cold start and a fresh proxy-token request per posting.Streaming uses the OpenAI-shaped create_chat_completion(stream=True) so the reasoning lands in the UI token by token. The live demo is at build-small-hackathon/job-search-assistant.
The entire Claude Code session that built this Space is published as an HuggingFace agent-traces dataset at build-small-hackathon/job-search-assistant-agent-trace. Raw JSONL events, native HuggingFace trace viewer, every dead end and recovery on the record. Useful if you want to see how this thing actually came together rather than read the cleaned-up version of it.
Drop your resume at huggingface.co/spaces/build-small-hackathon/job-search-assistant. Stop sifting.
Two adapters beat one. I tried folding query generation and fit evaluation into a single LoRA. The model leaked formatting both ways, JSON on the query task and prose on the eval. Splitting them into two heads on the same base, hot-swapped per call, killed the whole class of bugs.
The teacher's prompt mattered more than the student's size. Rewriting the teacher's labelling prompt to score against specific resume details ("four years of Rust; the role asks for five" instead of "strong technical match") propagated through distillation. The student picked up the same habit.