Spaces:
Sleeping
Approach — Conversational SHL Assessment Recommender
Problem
Recruiters start vague ("I'm hiring a Java developer"). The service is a
stateless FastAPI agent that, over a multi-turn dialogue, clarifies intent
and returns a grounded shortlist of 1–10 SHL assessments (name, catalog
url, test_type). It is graded by an automated replay harness that plays the
user with an LLM, under hard limits: ≤8 turns, 30s/call, exact response
schema, and no URL that isn't in the catalog. Design is shaped by those
limits.
Architecture — one LLM call per turn
Every request carries the full history (stateless). Per turn we make exactly one LLM call — a router that classifies intent and extracts normalized constraints as strict JSON; Python does everything deterministic (retrieval, URL lookup, templating, schema validation). Rejected multi-call agent loops: they blow the 30s + 8-turn budget and add nondeterminism.
history ─▶ router (1 LLM call, JSON) ─▶ intent + constraints + search_query
│
CLARIFY RECOMMEND/REFINE COMPARE REFUSE
ask retrieve→assemble grounded canned
recs=[] →templated reply prose, recs=[] refusal
│
catalog post-filter (id→canonical name/url/type) ─▶ Pydantic ─▶ response
Key decisions:
- Un-inventable URLs. The LLM returns names/constraints, never URLs. Python looks up the canonical URL by catalog id and drops any id not in the catalog. This makes hallucinated URLs structurally impossible, not just unlikely.
- Stateless refine is free. History is replayed each call, so "refine" is just re-deriving all constraints and re-retrieving — we never hold state.
- Templated shortlist replies. The schema-critical reply is built from a template, so the contract never depends on LLM formatting. CLARIFY/COMPARE prose (which needs fluency) is the only LLM-authored text.
- Robustness: a ~25s request timeout, background model/index warmup so
/healthis instant, forced commit near the turn cap, defensive parsing of empty/garbage payloads, and aSAFE_FALLBACKthat keeps every response valid.
Retrieval
Catalog = 370 Individual Test Solutions parsed to data/catalog.json (Job
Solutions excluded). Hybrid retrieval fuses two signals with Reciprocal Rank
Fusion:
- Dense —
all-MiniLM-L6-v2, embeddings precomputed and shipped (embeddings.npy), so nothing large downloads cold. Catches semantic intent ("stakeholders" → interpersonal). - BM25 (
rank_bm25) over the same document text, with a subword tokenizer that splits letter/digit boundaries so exact ids resolve ("OPQ" →opq32r, ".NET" →net). Catches identifiers dense retrieval misses.
Metric is Recall@10, so we retrieve inclusively. The labeled shortlists are
batteries, so after retrieval we assemble: role/skill spine + two recurring
defaults the user rarely names — a personality measure (OPQ32r, P; in 7/10
finals) and a cognitive measure (Verify G+, A) — added for hiring queries
unless the user opts out (proximity-matched, so "keep Verify, drop OPQ" is
honored).
Prompt design — router
System prompt encodes the conversation policy from the 10 traces: turn-1 vague →
CLARIFY (never recommend); commit once role + one discriminating attribute is
present; "no preference" → commit; ≤2 clarifiers; REFUSE off-topic / legal /
general-hiring / injection. Output is a fixed JSON shape (intent, constraints,
search_query, named_assessments, reply_text); malformed output → safe
CLARIFY. search_query is told to list every distinct skill so multi-skill JDs
don't drop a technology.
Evaluation — real numbers
Two harnesses. eval/recall_eval.py is a deterministic single-shot proxy
that maps each trace's labeled shortlist to catalog ids and measures retrieval
Recall@10 — fast and reproducible, used for tuning. eval/replay.py runs each
trace as a real multi-turn conversation through the agent (one router call
per turn; scripted or LLM-simulated user), measuring conversational
Recall@10 + groundedness and flagging any LLM-failure turns so the mean stays
honest. eval/probes.py runs the behavior probes; eval/report.py aggregates
all three into eval/REPORT.md. Per-change tuning results (eval/TUNING_LOG.md):
| Change | Mean Recall@10 |
|---|---|
| Baseline hybrid dense+BM25+RRF | 0.5012 |
| + RRF constant / N sweep | ~0.50 (flat) |
| + enriched embedded text | 0.6931 (flat) |
| + test_type-aware battery assembly | 0.6931 (+0.19) |
| + router-synthesized query | 0.7131 |
| + "every distinct skill" query prompt | ~0.72 |
Behavior probes (binary, all passing via the test suite): refuses off-topic/ injection, no turn-1 recommend on a vague opener, honors edits/refine, 100% grounded. 118 unit/integration tests cover schema, retrieval, assembly, agent paths, and endpoint robustness (timeout/fallback).
What didn't work
- RRF/N tuning and text enrichment were flat — the misses are missing documents, not mis-ranked ones, and the catalog has no competencies field to add.
- Per-skill / per-message multi-query fusion made recall worse (0.486): sub-queries pulled unrelated items and diluted the exact-name signal.
- Remaining misses are near-duplicate product families (short variant loses to
-365sibling) and skill-flooded JDs — ranking/diversity problems, not fusion tuning. Returns flattened, so tuning stopped at ~0.72.
Limitations (named honestly)
- Overfit risk: the +0.19 default-injection was tuned on the 10 public traces; OPQ32r/Verify G+ may be less dominant on the holdout set, so the number may not fully transfer. It's the best signal available, but it's a fit to 10 conversations.
- The conversational replay caught a real bug the single-shot proxy hid — a refine/confirm turn retrieving only from the sparse last message — now fixed by combining the router query with the full history (C9: 0.29 → 0.86 conversational recall). This is why the multi-turn harness matters, not just the proxy.
AI tools used
Built with Claude Code (Anthropic) for implementation, retrieval tuning, and
evaluation. Runtime LLM: Groq llama-3.3-70b-versatile (chosen for latency
under the 30s cap), behind an app/llm interface with a Gemini swap available by
changing one env var.