# 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 `/health` is instant, forced commit near the turn cap, defensive parsing of empty/garbage payloads, and a `SAFE_FALLBACK` that 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 `-365` sibling) 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.