Spaces:
Sleeping
Sleeping
Commit Β·
7488b2f
0
Parent(s):
Deploy SHL assessment recommender
Browse files- .gitattributes +1 -0
- .gitignore +19 -0
- Dockerfile +13 -0
- README.md +13 -0
- data/catalog.index +3 -0
- data/catalog_meta.json +1 -0
- main.py +45 -0
- models.py +23 -0
- requirements.txt +25 -0
- shl_product_catalog.json +0 -0
- temp.py +311 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
data/catalog.index filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# Environment variables & secrets
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
|
| 16 |
+
# Editor folders
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
| 19 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /code
|
| 4 |
+
|
| 5 |
+
# Copy requirements and install
|
| 6 |
+
COPY requirements.txt /code/requirements.txt
|
| 7 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy application files and prebuilt index
|
| 10 |
+
COPY . /code
|
| 11 |
+
|
| 12 |
+
# HF Spaces runs on port 7860 by default
|
| 13 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SHL Recommender
|
| 3 |
+
emoji: π€
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# SHL Assessment Recommender
|
| 12 |
+
|
| 13 |
+
This is a conversational FastAPI agent for recommending SHL assessments based on user queries, built using FastAPI, FAISS, and Gemini embeddings.
|
data/catalog.index
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4c72e695173f0c19ecc39376b2b9afe50b17837243b6a72fecf5dae5be75236f
|
| 3 |
+
size 1158189
|
data/catalog_meta.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
["4302", "3827", "4094", "4099", "4018", "4016", "4165", "4178", "4187", "4179", "4188", "4168", "4019", "3778", "4069", "4004", "4223", "4159", "4311", "4028", "4160", "4153", "4021", "4030", "4011", "4073", "4075", "4022", "4115", "4023", "4017", "3786", "4219", "3980", "3981", "3984", "3982", "3983", "4222", "3985", "4229", "4026", "4025", "3988", "4206", "4149", "4161", "4036", "4037", "244", "536", "4038", "4039", "4122", "4077", "4044", "4012", "4024", "4150", "4045", "4046", "4104", "4189", "4296", "4034", "4032", "3458", "4052", "4050", "3933", "3931", "4053", "4186", "778", "779", "785", "4013", "56", "731", "4162", "4101", "4003", "4221", "4239", "4059", "4060", "4062", "1102", "4148", "4009", "4001", "4031", "4002", "4000", "4139", "4180", "4063", "3856", "4287", "3934", "3939", "3935", "3936", "3937", "3938", "4065", "743", "3849", "3899", "4127", "380", "381", "3992", "4083", "4067", "382", "383", "4090", "4071", "4072", "4113", "4043", "4048", "4125", "4066", "4078", "4301", "741", "3900", "3901", "4074", "331", "3845", "4284", "4285", "4076", "4080", "4081", "3999", "4169", "4155", "4005", "4054", "4055", "4007", "249", "333", "4102", "4040", "88", "4084", "4158", "4056", "3809", "4033", "3989", "4086", "4085", "4087", "4152", "3990", "4299", "205", "4170", "742", "3903", "3902", "3904", "4157", "4291", "4293", "4295", "4292", "4294", "4008", "4089", "4006", "4176", "4058", "4119", "3951", "3950", "3949", "3948", "4027", "4091", "4092", "4208", "4207", "3785", "4212", "3789", "3807", "4211", "4210", "4047", "4093", "4174", "4096", "4010", "724", "1308", "1306", "1309", "1048", "4015", "3993", "3995", "4193", "4097", "3994", "4098", "615", "4100", "3997", "4041", "720", "4116", "727", "4298", "748", "749", "750", "4300", "752", "754", "753", "4307", "1050", "1060", "1059", "1058", "1061", "756", "757", "4286", "758", "4289", "1067", "759", "4106", "4105", "4145", "4156", "4154", "4107", "3998", "4108", "4109", "4070", "4110", "4088", "4114", "4061", "4111", "4112", "4103", "3746", "3484", "4042", "4118", "4117", "4095", "4121", "4120", "219", "3769", "393", "4123", "4124", "4177", "3472", "543", "3942", "4204", "4202", "4203", "4126", "697", "395", "4129", "4128", "3932", "3930", "726", "725", "4230", "4283", "4233", "4288", "4130", "4131", "4132", "4133", "4136", "4134", "4135", "4082", "4137", "4064", "4171", "4138", "4140", "3968", "3947", "3946", "3971", "3972", "4141", "4209", "4218", "4205", "4014", "116", "4142", "4051", "40", "4143", "4144", "4035", "4282", "4146", "4147", "3996", "4151", "4216", "3986", "4217", "3987", "4197", "4198", "4199", "4200", "4049", "4079", "4020", "4167", "251", "336", "4201", "4175", "735", "734", "733", "4172", "4173", "3733", "3941", "3908", "3734", "3836", "3745", "3768", "3906", "3940", "3945", "3970", "3969", "3976", "3974", "4290", "4215", "4163", "399", "400", "4164", "3459", "3991", "4068", "4183", "4184", "4185", "17", "18", "4166", "4297", "744"]
|
main.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import traceback
|
| 2 |
+
from fastapi import FastAPI, HTTPException
|
| 3 |
+
from fastapi.responses import JSONResponse
|
| 4 |
+
from models import ChatRequest, ChatResponse, Recommendation
|
| 5 |
+
import temp # Imports and loads catalog, index, and advisor configuration once
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title="SHL Assessment Recommender")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@app.get("/health")
|
| 11 |
+
def health():
|
| 12 |
+
return {"status": "ok"}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@app.post("/chat", response_model=ChatResponse)
|
| 16 |
+
def chat(req: ChatRequest):
|
| 17 |
+
messages = [{"role": m.role, "content": m.content} for m in req.messages]
|
| 18 |
+
|
| 19 |
+
if not messages or messages[-1]["role"] != "user":
|
| 20 |
+
raise HTTPException(
|
| 21 |
+
status_code=400,
|
| 22 |
+
detail="messages must be non-empty and end with a user turn",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
result = temp.agent_turn(messages)
|
| 26 |
+
|
| 27 |
+
return ChatResponse(
|
| 28 |
+
reply=result["reply"],
|
| 29 |
+
recommendations=[Recommendation(**r) for r in result["recommendations"]],
|
| 30 |
+
end_of_conversation=result["end_of_conversation"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.exception_handler(Exception)
|
| 35 |
+
async def unhandled_exception_handler(request, exc):
|
| 36 |
+
print("Unhandled exception occurred:")
|
| 37 |
+
traceback.print_exc()
|
| 38 |
+
return JSONResponse(
|
| 39 |
+
status_code=200,
|
| 40 |
+
content={
|
| 41 |
+
"reply": "Something went wrong on our side β could you repeat that?",
|
| 42 |
+
"recommendations": [],
|
| 43 |
+
"end_of_conversation": False,
|
| 44 |
+
},
|
| 45 |
+
)
|
models.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Message(BaseModel):
|
| 6 |
+
role: str
|
| 7 |
+
content: str
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ChatRequest(BaseModel):
|
| 11 |
+
messages: List[Message]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Recommendation(BaseModel):
|
| 15 |
+
name: str
|
| 16 |
+
url: str
|
| 17 |
+
test_type: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ChatResponse(BaseModel):
|
| 21 |
+
reply: str
|
| 22 |
+
recommendations: List[Recommendation]
|
| 23 |
+
end_of_conversation: bool
|
requirements.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ Web Framework ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
fastapi==0.115.5
|
| 3 |
+
uvicorn[standard]==0.32.1
|
| 4 |
+
python-dotenv==1.0.1
|
| 5 |
+
|
| 6 |
+
# ββ Data validation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
pydantic==2.10.3
|
| 8 |
+
|
| 9 |
+
# ββ LLM (Groq) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
groq==0.13.0
|
| 11 |
+
|
| 12 |
+
# ββ Embeddings (Gemini API β no local model download) ββββββββββββββββββββββββ
|
| 13 |
+
google-genai==1.16.0
|
| 14 |
+
|
| 15 |
+
# ββ Vector store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
faiss-cpu==1.9.0
|
| 17 |
+
|
| 18 |
+
# ββ Numerical / ML utilities βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
numpy==1.26.4
|
| 20 |
+
|
| 21 |
+
# ββ HTTP client (optional, for health-check tests) βββββββββββββββββββββββββββ
|
| 22 |
+
httpx==0.28.1
|
| 23 |
+
|
| 24 |
+
# ββ Dev / testing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
pytest==8.3.4
|
shl_product_catalog.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
temp.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import numpy as np
|
| 5 |
+
import faiss
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from google import genai
|
| 8 |
+
from google.genai import types
|
| 9 |
+
from groq import Groq
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
INDEX_PATH = "data/catalog.index"
|
| 14 |
+
META_PATH = "data/catalog_meta.json"
|
| 15 |
+
CATALOG_PATH = "shl_product_catalog.json"
|
| 16 |
+
EMBED_MODEL = "models/gemini-embedding-2"
|
| 17 |
+
EMBED_DIM = 768
|
| 18 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 19 |
+
MAX_USER_TURNS = 4 # hard budget: 4 user + 4 agent = 8 total, matches evaluator cap
|
| 20 |
+
|
| 21 |
+
gemini_client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
|
| 22 |
+
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
|
| 23 |
+
index = faiss.read_index(INDEX_PATH)
|
| 24 |
+
meta = json.load(open(META_PATH))
|
| 25 |
+
catalog_list = json.load(open(CATALOG_PATH, encoding="utf-8"))
|
| 26 |
+
catalog_map = {item["entity_id"]: item for item in catalog_list}
|
| 27 |
+
# url -> entity_id for fast post-LLM validation
|
| 28 |
+
url_to_id = {item["link"]: item["entity_id"] for item in catalog_list}
|
| 29 |
+
|
| 30 |
+
# ββ Key-to-letter mapping (derived from catalog keys field) ββββββββββββββββββ
|
| 31 |
+
KEY_LETTER = {
|
| 32 |
+
"Knowledge & Skills": "K",
|
| 33 |
+
"Personality & Behavior": "P",
|
| 34 |
+
"Ability & Aptitude": "A",
|
| 35 |
+
"Simulations": "S",
|
| 36 |
+
"Biodata & Situational Judgment": "B",
|
| 37 |
+
"Competencies": "C",
|
| 38 |
+
"Development & 360": "D",
|
| 39 |
+
"Assessment Exercises": "E",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def keys_to_type(keys: list[str]) -> str:
|
| 44 |
+
"""Return comma-joined letter codes for a catalog item's keys list."""
|
| 45 |
+
seen, letters = set(), []
|
| 46 |
+
for k in keys:
|
| 47 |
+
letter = KEY_LETTER.get(k)
|
| 48 |
+
if letter and letter not in seen:
|
| 49 |
+
seen.add(letter)
|
| 50 |
+
letters.append(letter)
|
| 51 |
+
return ",".join(letters) if letters else "K"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Retrieval ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
# All semantic decisions (EOC, intent classification, refusals) are owned by the LLM.
|
| 56 |
+
# Retrieval uses fixed anchor queries unconditionally β no regex routing needed.
|
| 57 |
+
|
| 58 |
+
_ANCHORS = [
|
| 59 |
+
# Cognitive / personality β useful for nearly all selection queries
|
| 60 |
+
"cognitive ability reasoning personality behaviour workplace",
|
| 61 |
+
# Development / reskilling β surfaces GSA, 360, dev reports
|
| 62 |
+
"development reskilling skills self-assessment 360 feedback report",
|
| 63 |
+
# Spoken language / SVAR β surfaces voice assessment variants by language
|
| 64 |
+
"spoken language voice assessment English Spanish accent SVAR",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def build_intent(messages: list[dict]) -> str:
|
| 69 |
+
"""
|
| 70 |
+
Consolidated intent string from conversation history.
|
| 71 |
+
The latest user message is repeated to weight recent refinements
|
| 72 |
+
(e.g. 'add AWS, drop REST') more heavily in the embedding.
|
| 73 |
+
"""
|
| 74 |
+
user_msgs = [m["content"] for m in messages if m["role"] == "user"]
|
| 75 |
+
if not user_msgs:
|
| 76 |
+
return ""
|
| 77 |
+
earlier = " ".join(user_msgs[:-1])
|
| 78 |
+
latest = user_msgs[-1]
|
| 79 |
+
return f"{earlier} {latest} {latest}".strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def retrieve(messages: list[dict], k_main: int = 15, k_anchor: int = 3) -> list[dict]:
|
| 83 |
+
"""
|
| 84 |
+
Returns a de-duplicated, rank-ordered list of catalog entries.
|
| 85 |
+
Rank order matters now: it's used as the fallback shortlist when the
|
| 86 |
+
LLM fails to commit by the turn cap (see agent_turn).
|
| 87 |
+
"""
|
| 88 |
+
intent = build_intent(messages)
|
| 89 |
+
|
| 90 |
+
# Primary search on the full conversation intent
|
| 91 |
+
_, idx_main = index.search(embed(intent), k_main)
|
| 92 |
+
|
| 93 |
+
# Fixed anchors β always run all three, let the LLM pick what's relevant.
|
| 94 |
+
# Kept behind the main results so the fallback path prioritises the
|
| 95 |
+
# query-specific matches over the generic anchors.
|
| 96 |
+
anchor_ids: list[int] = []
|
| 97 |
+
for anchor in _ANCHORS:
|
| 98 |
+
_, idx_anchor = index.search(embed(anchor), k_anchor)
|
| 99 |
+
anchor_ids += list(idx_anchor[0])
|
| 100 |
+
|
| 101 |
+
seen, results = set(), []
|
| 102 |
+
for i in list(idx_main[0]) + anchor_ids:
|
| 103 |
+
if i != -1 and meta[i] not in seen:
|
| 104 |
+
seen.add(meta[i])
|
| 105 |
+
results.append(catalog_map[meta[i]])
|
| 106 |
+
return results
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def embed(text: str) -> np.ndarray:
|
| 110 |
+
result = gemini_client.models.embed_content(
|
| 111 |
+
model=EMBED_MODEL,
|
| 112 |
+
contents=text,
|
| 113 |
+
config=types.EmbedContentConfig(
|
| 114 |
+
task_type="RETRIEVAL_QUERY",
|
| 115 |
+
output_dimensionality=EMBED_DIM,
|
| 116 |
+
),
|
| 117 |
+
)
|
| 118 |
+
vec = np.array(result.embeddings[0].values, dtype="float32")
|
| 119 |
+
vec /= np.linalg.norm(vec)
|
| 120 |
+
return vec.reshape(1, -1)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ββ Catalog snippet formatter ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
def format_snippets(items: list[dict]) -> str:
|
| 125 |
+
parts = []
|
| 126 |
+
for i, item in enumerate(items, 1):
|
| 127 |
+
type_letter = keys_to_type(item["keys"])
|
| 128 |
+
snippet = (
|
| 129 |
+
f"[{i}] name={item['name']}\n"
|
| 130 |
+
f" url={item['link']}\n"
|
| 131 |
+
f" test_type={type_letter} | "
|
| 132 |
+
f"categories={', '.join(item['keys'])} | "
|
| 133 |
+
f"duration={item['duration'] or 'N/A'} "
|
| 134 |
+
f"| remote={item['remote']} | adaptive={item['adaptive']}\n"
|
| 135 |
+
f" levels={', '.join(item['job_levels'])}\n"
|
| 136 |
+
f" {item['description'][:400]}"
|
| 137 |
+
)
|
| 138 |
+
parts.append(snippet)
|
| 139 |
+
return "\n\n".join(parts)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def to_recommendation(item: dict) -> dict:
|
| 143 |
+
return {
|
| 144 |
+
"name": item["name"],
|
| 145 |
+
"url": item["link"],
|
| 146 |
+
"test_type": keys_to_type(item["keys"]),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ββ Post-LLM recommendation validator βββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
def validate_recommendations(recs: list[dict] | None) -> list[dict] | None:
|
| 152 |
+
"""
|
| 153 |
+
Strip any recommendation whose URL is not in the catalog.
|
| 154 |
+
Also correct the test_type letter from the catalog (never trust the LLM for this),
|
| 155 |
+
and de-dupe on entity_id so near-duplicate SKUs (e.g. two 'Verify G+' variants)
|
| 156 |
+
can't both slip through if the LLM names both.
|
| 157 |
+
"""
|
| 158 |
+
if not recs:
|
| 159 |
+
return recs
|
| 160 |
+
seen_ids, validated = set(), []
|
| 161 |
+
for r in recs:
|
| 162 |
+
url = r.get("url", "")
|
| 163 |
+
entity_id = url_to_id.get(url)
|
| 164 |
+
if entity_id is None:
|
| 165 |
+
# Try a forgiving match: strip trailing slash differences
|
| 166 |
+
url_norm = url.rstrip("/") + "/"
|
| 167 |
+
entity_id = url_to_id.get(url_norm)
|
| 168 |
+
if entity_id is None or entity_id in seen_ids:
|
| 169 |
+
# URL not in catalog, or a duplicate β drop this item
|
| 170 |
+
continue
|
| 171 |
+
seen_ids.add(entity_id)
|
| 172 |
+
validated.append(to_recommendation(catalog_map[entity_id]))
|
| 173 |
+
return validated if validated else None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ββ System prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
+
# ββ System prompt ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 178 |
+
SYSTEM_PROMPT = """You are an SHL advisor. Recommend from CATALOG ENTRIES below only. Output JSON only.
|
| 179 |
+
|
| 180 |
+
RULES:
|
| 181 |
+
1. Gather only: role, yoe, tech stack, purpose (selection/dev) in turns 1-3. Ask questions; recommend early only if confident and explicitly ask for feedback/extra requirements unless context is fully clear.
|
| 182 |
+
2. Turn {current_turn} of 4. By turn 4, output 7-10 recommendations and set end_of_conversation=true.
|
| 183 |
+
3. Recommend leadership/personality for seniors; cognitive/coding for juniors. No hallucinations.
|
| 184 |
+
4. Refuse legal/general HR advice. Refine shortlist in-place on feedback. Compare using catalog only.
|
| 185 |
+
|
| 186 |
+
CATALOG ENTRIES:
|
| 187 |
+
{catalog_snippets}
|
| 188 |
+
|
| 189 |
+
OUTPUT:
|
| 190 |
+
{{"reply": "text reply/question", "recommendations": [], "end_of_conversation": false}}"""
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ββ LLM call βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
def call_llm(messages: list[dict], snippets: str, current_turn: int) -> str:
|
| 195 |
+
system = (
|
| 196 |
+
SYSTEM_PROMPT
|
| 197 |
+
.replace("{catalog_snippets}", snippets)
|
| 198 |
+
.replace("{current_turn}", str(current_turn))
|
| 199 |
+
.replace("{max_turns}", str(MAX_USER_TURNS))
|
| 200 |
+
.replace("{last_clarify_turn}", str(MAX_USER_TURNS - 1))
|
| 201 |
+
)
|
| 202 |
+
resp = groq_client.chat.completions.create(
|
| 203 |
+
model=GROQ_MODEL,
|
| 204 |
+
messages=[{"role": "system", "content": system}] + messages,
|
| 205 |
+
temperature=0.2,
|
| 206 |
+
max_tokens=1500,
|
| 207 |
+
response_format={"type": "json_object"},
|
| 208 |
+
)
|
| 209 |
+
return resp.choices[0].message.content
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ββ JSON parser βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 213 |
+
def parse(raw: str) -> dict:
|
| 214 |
+
# Strip markdown code fences if present (belt-and-braces even with response_format=json_object)
|
| 215 |
+
cleaned = re.sub(r"```(?:json)?", "", raw).strip()
|
| 216 |
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 217 |
+
if not match:
|
| 218 |
+
raise json.JSONDecodeError("no JSON object found", raw, 0)
|
| 219 |
+
return json.loads(match.group())
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ββ Main agent turn βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
def agent_turn(messages: list[dict]) -> dict:
|
| 224 |
+
"""
|
| 225 |
+
Full agent pipeline for one turn.
|
| 226 |
+
Returns a dict: {reply, recommendations, end_of_conversation}
|
| 227 |
+
Guarantees: on the last allowed user turn, recommendations is always
|
| 228 |
+
non-empty (falls back to top retrieved catalog items) and
|
| 229 |
+
end_of_conversation is always true.
|
| 230 |
+
"""
|
| 231 |
+
# Count user turns so far (the last message is the current user turn)
|
| 232 |
+
current_turn = sum(1 for m in messages if m["role"] == "user")
|
| 233 |
+
is_last_turn = current_turn >= MAX_USER_TURNS
|
| 234 |
+
|
| 235 |
+
# 1. Retrieve catalog items (rank-ordered β first items are the best matches)
|
| 236 |
+
catalog_items = retrieve(messages)
|
| 237 |
+
snippets = format_snippets(catalog_items)
|
| 238 |
+
|
| 239 |
+
# 2. LLM call β all intent decisions owned by the model
|
| 240 |
+
try:
|
| 241 |
+
raw = call_llm(messages, snippets, current_turn)
|
| 242 |
+
parsed = parse(raw)
|
| 243 |
+
except (json.JSONDecodeError, Exception):
|
| 244 |
+
parsed = {}
|
| 245 |
+
|
| 246 |
+
reply = parsed.get("reply") or ""
|
| 247 |
+
raw_recs = parsed.get("recommendations")
|
| 248 |
+
|
| 249 |
+
# 3. Validate URLs + test_types; normalise to list
|
| 250 |
+
if isinstance(raw_recs, list) and raw_recs:
|
| 251 |
+
recommendations = validate_recommendations(raw_recs) or []
|
| 252 |
+
else:
|
| 253 |
+
recommendations = []
|
| 254 |
+
recommendations = recommendations[:10]
|
| 255 |
+
|
| 256 |
+
# 4. Force-commit on the last turn: guarantee a non-empty, catalog-grounded
|
| 257 |
+
# shortlist even if the LLM stalled, refused, or returned malformed JSON.
|
| 258 |
+
if is_last_turn and not recommendations:
|
| 259 |
+
recommendations = [to_recommendation(item) for item in catalog_items[:7]]
|
| 260 |
+
if not reply:
|
| 261 |
+
reply = (
|
| 262 |
+
"Based on everything discussed, here is a shortlist that fits "
|
| 263 |
+
"your requirements."
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# 5. EOC: respect the LLM's signal, but always force true on the last turn.
|
| 267 |
+
eoc = bool(parsed.get("end_of_conversation", False)) or is_last_turn
|
| 268 |
+
|
| 269 |
+
return {
|
| 270 |
+
"reply": reply,
|
| 271 |
+
"recommendations": recommendations,
|
| 272 |
+
"end_of_conversation": eoc,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ββ CLI entrypoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 277 |
+
def main():
|
| 278 |
+
messages = []
|
| 279 |
+
print("SHL Assessment Advisor")
|
| 280 |
+
print("-" * 50)
|
| 281 |
+
|
| 282 |
+
while True:
|
| 283 |
+
user_input = input("You: ").strip()
|
| 284 |
+
if not user_input or user_input.lower() in ("quit", "exit"):
|
| 285 |
+
break
|
| 286 |
+
|
| 287 |
+
messages.append({"role": "user", "content": user_input})
|
| 288 |
+
result = agent_turn(messages)
|
| 289 |
+
|
| 290 |
+
reply = result["reply"]
|
| 291 |
+
recommendations = result["recommendations"]
|
| 292 |
+
eoc = result["end_of_conversation"]
|
| 293 |
+
|
| 294 |
+
print(f"\nAgent: {reply}")
|
| 295 |
+
|
| 296 |
+
if recommendations:
|
| 297 |
+
print("\nRecommendations:")
|
| 298 |
+
for i, r in enumerate(recommendations, 1):
|
| 299 |
+
print(f" {i}. [{r['test_type']}] {r['name']}")
|
| 300 |
+
print(f" {r['url']}")
|
| 301 |
+
|
| 302 |
+
print()
|
| 303 |
+
messages.append({"role": "assistant", "content": reply})
|
| 304 |
+
|
| 305 |
+
if eoc:
|
| 306 |
+
print("Conversation complete.")
|
| 307 |
+
break
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
if __name__ == "__main__":
|
| 311 |
+
main()
|