shl-recommender / APPROACH.md
Eshit's picture
HF Space deploy snapshot
5733f37
|
Raw
History Blame Contribute Delete
6.55 kB

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:

  • Denseall-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.