Project
Jobtriage
A job-search agent over Sweden's public JobTech API that turns a profile and a question into a visual shortlist, for any profession.
problem
Sweden's job board can filter by occupation, region, and deadline. It cannot answer "which of these mention agentic systems and close before Friday", so I built something that could, against the same public API.
Job boards return a list. The question a candidate has is which roles fit them, why, which one closes first, and which two are worth putting side by side. That is a decision made against a profile, and a ranked list is not shaped for it.
try
Two paths share the same interface. A visitor without a key gets a mock replay of four recorded conversations, enough to drive the canvas. A visitor with a key from Anthropic, OpenAI, or Gemini drives the live agent against JobTech data. The key stays in the browser and is never stored on the server.
The deployed path searches the live JobTech taxonomy and JobSearch APIs, so results are current. Local development runs against a fixed corpus instead, which is what makes a retrieval change measurable rather than merely different.
canvas
The agent does not stop at a paragraph of text. Its tool calls drive a React Flow canvas, so the same set of ads becomes triage clusters, a deadline timeline, a side-by-side comparison, or a pinned shortlist.
The canvas makes the next decision visible rather than leaving a candidate to reconstruct it from a chat transcript.
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. The table is the local set. The
deployed agent carries a smaller one, since corpus tools such as triageBatch and deadlineWatch have nothing to search
against live data.
| Data tool | Spatial tool |
|---|---|
| searchJobs | placeAds |
| triageBatch | groupAds |
| matchProfile | connectProfileToAds |
| compareRoles | pairAdsForCompare |
| deadlineWatch | placeAdsOnTimeline |
| trackStatus | markStatus |
retrieval
Finding the right ad is the thing everything else rests on, so it was measured rather than assumed. The evaluation covers the local corpus, not the live JobTech path.
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, which is
the opposite of the result hybrid retrieval is usually assumed to
give. 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, English | 0.700 | 0.855 | 384 |
| e5-base, multilingual | 0.780 | 0.965 | 768 |
| e5-large, multilingual | 0.860 | 0.945 | 1024 |
English-only MiniLM gives up 11 points of recall@10 against e5-base on the Swedish set, and fusing BM25 back in recovers 7 of them. That is the strongest case for keeping hybrid: it holds up when the encoder is weaker than the one it was tuned on. e5-large lifts P@1 by another 8 points over e5-base, for about 70% more memory and dense latency, and hands back two points of recall@10, so e5-base ships as the default.
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 and
multilingual-e5-basedense, fused by RRF over SQLite - Providers
- Anthropic, OpenAI, Gemini by visitor key, a no-key mock replay, local Ollama in development
- Domain
- Cloudflare A record fronting Vercel
React Flow surfaces four canonical views, each carrying custom nodes rather than the React Flow defaults.
The agent is measured too, on ten scripted probes against the live path. It passed five. It picked the right tools 92% of the time and then answered incompletely, which makes the gap a prompt and answer-quality problem rather than a routing one. That is the next thing to fix.