problem
I wanted a project where a language model drove the interface rather than sitting behind it. Handing it the canvas, the filters, and the layout is a different problem from returning a good answer.
Job boards rank for what the platform earns from rather than for what fits the candidate. Profile-driven match is a first-class operation here rather than a filter bolted onto a search box.
system
Two postures share one agent shell: same prompt, same tools, different data path.
The deployed posture runs against the JobTech taxonomy and JobSearch APIs. The local CLI and local dev run the hybrid retrieval stack against a SQLite corpus.
- Frontend
- Next.js App Router on Vercel, Vercel AI SDK
- Backend
- FastAPI on Cloud Run europe-west1, 1Gi memory
- Retrieval
- BM25 + multilingual-e5-base dense + RRF over SQLite
- BYOK
- Anthropic, OpenAI, Gemini, local Ollama, mock replay
- Domain
- Cloudflare A record fronting Vercel
Two entry paths carry the surface. Mock replay lets a recruiter drive the agent in five seconds with no key. BYOK lets a technical visitor drive it with their own. Most agent demos offer neither.
retrieval
Both paths depend on the same thing underneath: finding the right ad. That was measured rather than assumed.
Measured on a 50-query Swedish golden set against a 59-ad corpus drawn from Spotify, Klarna, Volvo Group, Volvo Cars, Ericsson, HT Engineering, Stig Ericsson Bil, Montico, and Isaksson Rekrytering. Embeddings from intfloat/multilingual-e5-base.
Three numbers repeat through this section: P@1 asks whether the top result is right, R@10 asks whether the right one is somewhere in the top ten, and p95 ms is the 95th-percentile query latency.
| configuration | P@1 | R@10 | p95 ms |
|---|---|---|---|
| filter-only | 0.020 | 0.150 | 0.0 |
| bm25-only | 0.680 | 0.920 | 1.2 |
| dense-only | 0.780 | 0.965 | 7.8 |
| hybrid | 0.720 | 0.950 | 15.2 |
Dense alone takes P@1 by 6 points over hybrid on this corpus.
Hybrid earns its place on adversarial queries where an exact
keyword carries the match, such as a model name or employer
jargon. An RRF score floor at
JOBTRIAGE_RRF_FLOOR=0.025
suppresses low-relevance noise at the API boundary.
| encoder | P@1 | R@10 | dim |
|---|---|---|---|
| MiniLM (en) | 0.700 | 0.855 | 384 |
| e5-base (ml) | 0.780 | 0.965 | 768 |
| e5-large (ml) | 0.860 | 0.945 | 1024 |
English-only MiniLM gives up 11 points of recall@10 against e5-base on the Swedish set. e5-large lifts P@1 by another 8 points over e5-base and hands back two points of recall@10, so e5-base ships as the balanced default. The MiniLM dense numbers run slightly suppressed because the e5 prefix tokens it never trained on read as noise. The encoder choice carries the result rather than decorating it.
agent
Retrieval finds the right ad. What the agent does with it once found is a separate problem.
Spatial tool pairings are pinned in the system prompt, so the agent fires a spatial tool after every data tool and the canvas answers the question rather than illustrating it.
- searchJobs
- placeAds
- triageBatch
- groupAds
- matchProfile
- connectProfileToAds
- compareRoles
- pairAdsForCompare
- deadlineWatch
- placeAdsOnTimeline
- trackStatus
- markStatus
React Flow surfaces four canonical views: triage clusters, deadline timeline, side-by-side compare, and pinned shortlist. Each view carries custom nodes rather than the React Flow defaults.