Spaces:
Sleeping
Sleeping
Commit ·
ca20ec1
1
Parent(s): 5793286
Initial commit of SHL Assessment Recommender
Browse files- .gitignore +4 -0
- Dockerfile +25 -0
- app/Llm_utils.py +28 -0
- app/Nlu.py +82 -0
- app/Planner.py +112 -0
- app/Responder.py +81 -0
- app/agent.py +133 -0
- app/config.py +10 -0
- app/database.py +79 -0
- app/main.py +154 -0
- app/schemas.py +51 -0
- requirements.txt +10 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official lightweight Python image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory
|
| 5 |
+
WORKDIR /code
|
| 6 |
+
|
| 7 |
+
# Create a non-root user for security
|
| 8 |
+
RUN useradd -m -u 1000 user
|
| 9 |
+
USER user
|
| 10 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 11 |
+
|
| 12 |
+
# Copy requirements first to leverage Docker cache
|
| 13 |
+
COPY --chown=user:user requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install dependencies
|
| 16 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy the rest of the application code
|
| 19 |
+
COPY --chown=user:user . .
|
| 20 |
+
|
| 21 |
+
# Expose port 7860
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Run the FastAPI application
|
| 25 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/Llm_utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class UpstreamUnavailableError(Exception):
|
| 5 |
+
"""Raised when the LLM provider fails even after retries (e.g. persistent 503s)."""
|
| 6 |
+
pass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def invoke_with_retry(chain, payload: dict, max_retries: int = 3, base_delay: float = 1.5):
|
| 10 |
+
"""Invokes a langchain chain with exponential backoff on transient failures.
|
| 11 |
+
|
| 12 |
+
The Hugging Face router + featherless-ai provider can return a 503 "Service
|
| 13 |
+
Unavailable" HTML page instead of JSON during cold starts or overload. The
|
| 14 |
+
OpenAI-compatible client then throws while trying to parse that as a chat
|
| 15 |
+
completion. Retrying with backoff handles the common transient case instead
|
| 16 |
+
of failing the whole turn on the first hiccup.
|
| 17 |
+
"""
|
| 18 |
+
last_error = None
|
| 19 |
+
for attempt in range(1, max_retries + 1):
|
| 20 |
+
try:
|
| 21 |
+
return chain.invoke(payload)
|
| 22 |
+
except Exception as e:
|
| 23 |
+
last_error = e
|
| 24 |
+
is_last = attempt == max_retries
|
| 25 |
+
print(f"[llm_utils] attempt {attempt}/{max_retries} failed: {e}")
|
| 26 |
+
if not is_last:
|
| 27 |
+
time.sleep(base_delay * (2 ** (attempt - 1))) # 1.5s, 3s, 6s...
|
| 28 |
+
raise UpstreamUnavailableError(str(last_error)) from last_error
|
app/Nlu.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_openai import ChatOpenAI
|
| 2 |
+
from langchain_core.prompts import PromptTemplate
|
| 3 |
+
from langchain_core.output_parsers import JsonOutputParser
|
| 4 |
+
from app.schemas import ConversationState
|
| 5 |
+
from app.config import HUGGINGFACEHUB_API_TOKEN
|
| 6 |
+
from app.Llm_utils import invoke_with_retry
|
| 7 |
+
|
| 8 |
+
class NLUStage:
|
| 9 |
+
"""Stage 1: Reads the conversation and extracts structured facts.
|
| 10 |
+
Never recommends tests or writes user-facing prose.
|
| 11 |
+
"""
|
| 12 |
+
def __init__(self):
|
| 13 |
+
# Using Llama-3.1-8B-Instruct
|
| 14 |
+
self.llm = ChatOpenAI(
|
| 15 |
+
model="meta-llama/Llama-3.1-8B-Instruct:novita",
|
| 16 |
+
api_key=HUGGINGFACEHUB_API_TOKEN,
|
| 17 |
+
base_url="https://router.huggingface.co/v1",
|
| 18 |
+
max_tokens=500,
|
| 19 |
+
temperature=0.0,
|
| 20 |
+
)
|
| 21 |
+
self.parser = JsonOutputParser(pydantic_object=ConversationState)
|
| 22 |
+
self.prompt = PromptTemplate(
|
| 23 |
+
template="""You read a conversation between a user and an SHL assessment consultant and extract facts. You do NOT answer the user, recommend tests, or write any reply.
|
| 24 |
+
|
| 25 |
+
SCOPE CHECK: set "in_scope" to false ONLY if the Latest User Message is completely unrelated to business, hiring, job roles, or SHL assessments. Vague statements like "We need a solution for senior leadership" ARE in-scope.
|
| 26 |
+
|
| 27 |
+
INTENT — pick exactly one:
|
| 28 |
+
- "new_request": describes a hiring need not yet discussed in this conversation.
|
| 29 |
+
- "clarifying_answer": answers a question the consultant just asked.
|
| 30 |
+
- "comparison_question": asks to compare or explain the difference between named tests.
|
| 31 |
+
- "confirmation": says an existing shortlist/answer is right and they're satisfied.
|
| 32 |
+
- "addition_removal": asks to add, drop, or swap a specific skill or test.
|
| 33 |
+
- "pushback_feedback": says the consultant's last reply was wrong, OR asks regulatory/legal compliance questions about a test's legality.
|
| 34 |
+
- "off_topic": completely unrelated to business/hiring.
|
| 35 |
+
|
| 36 |
+
Extract from the FULL conversation so far:
|
| 37 |
+
- role_summary: a short phrase describing who this is for, or null if truly unknown.
|
| 38 |
+
- purpose: selection vs. development, or another explicit purpose the user gave, or null if not stated.
|
| 39 |
+
- topic_keywords: list of skills, tech stack, domain, or role-type words useful for a catalog search.
|
| 40 |
+
- compared_tests: test names the user wants compared, else [].
|
| 41 |
+
- ready_to_recommend: true if the user provided BOTH a job role AND at least one specific requirement (skills, traits, industry context, or test type).
|
| 42 |
+
- CONSULTANT BEHAVIOR: Do not withhold recommendations to get a "perfect" request. If the user provides a role and key attributes (e.g., "Plant Operator, safety-focused"), set this to TRUE immediately. You can always refine the shortlist in the next turn if they give more info. Set this to false ONLY if the request is extremely vague (e.g., "I need a test").
|
| 43 |
+
EXAMPLES OF SUFFICIENT VS INSUFFICIENT INFO:
|
| 44 |
+
|
| 45 |
+
Latest User Message: "We need a solution for senior leadership."
|
| 46 |
+
{{"in_scope": true, "intent": "new_request", "role_summary": "senior leadership", "purpose": null, "topic_keywords": ["senior leadership"], "compared_tests": [], "ready_to_recommend": false, "missing_info_question": "Happy to help narrow that down. What specific skills or responsibilities are you looking to assess for this leadership pool?"}}
|
| 47 |
+
|
| 48 |
+
Latest User Message: "Hiring graduate financial analysts — final-year students, no work experience. We need numerical reasoning and a finance knowledge test."
|
| 49 |
+
{{"in_scope": true, "intent": "new_request", "role_summary": "graduate financial analysts", "purpose": "selection", "topic_keywords": ["financial analyst", "numerical reasoning", "finance", "graduate"], "compared_tests": [], "ready_to_recommend": true, "missing_info_question": null}}
|
| 50 |
+
|
| 51 |
+
Latest User Message: "We're hiring plant operators for a chemical facility. Safety is absolute top priority — reliability, procedure compliance, never cutting corners."
|
| 52 |
+
{{"in_scope": true, "intent": "new_request", "role_summary": "plant operators, chemical facility", "purpose": "selection", "topic_keywords": ["plant operator", "safety", "reliability", "procedure compliance", "manufacturing"], "compared_tests": [], "ready_to_recommend": true, "missing_info_question": null}}
|
| 53 |
+
|
| 54 |
+
Conversation so far:
|
| 55 |
+
{history}
|
| 56 |
+
|
| 57 |
+
Latest User Message:
|
| 58 |
+
{input}
|
| 59 |
+
|
| 60 |
+
Respond with ONLY the JSON object matching this schema, nothing else:
|
| 61 |
+
{format_instructions}""",
|
| 62 |
+
input_variables=["history", "input"],
|
| 63 |
+
partial_variables={"format_instructions": self.parser.get_format_instructions()},
|
| 64 |
+
)
|
| 65 |
+
self.chain = self.prompt | self.llm | self.parser
|
| 66 |
+
|
| 67 |
+
def run(self, history: str, latest_message: str) -> dict:
|
| 68 |
+
try:
|
| 69 |
+
return invoke_with_retry(self.chain, {"history": history, "input": latest_message})
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"[NLU] Fallback triggered: {e}")
|
| 72 |
+
# If the API drops completely, force a bypass so the user isn't trapped
|
| 73 |
+
return {
|
| 74 |
+
"in_scope": True,
|
| 75 |
+
"intent": "new_request",
|
| 76 |
+
"role_summary": "Candidate",
|
| 77 |
+
"purpose": "selection",
|
| 78 |
+
"topic_keywords": [w for w in latest_message.replace("'", "").split() if len(w) > 4],
|
| 79 |
+
"compared_tests": [],
|
| 80 |
+
"ready_to_recommend": True,
|
| 81 |
+
"missing_info_question": None,
|
| 82 |
+
}
|
app/Planner.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 2 of the pipeline: pure Python decision logic. No LLM call happens here.
|
| 2 |
+
|
| 3 |
+
This is the core fix for the 'rigid' / hallucinated behavior you were seeing: the
|
| 4 |
+
old single prompt asked a 3B model to *decide* — in the same breath as writing
|
| 5 |
+
prose — whether it had enough info, whether the user was confirming, whether a
|
| 6 |
+
question was a comparison, etc. Small models are unreliable at that kind of
|
| 7 |
+
in-context judgment call. Here, the judgment call is a plain if/else over facts
|
| 8 |
+
the NLU stage already extracted, so it's 100% consistent and free.
|
| 9 |
+
"""
|
| 10 |
+
from typing import List, Dict, Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Action:
|
| 14 |
+
def __init__(self, kind: str, **kwargs):
|
| 15 |
+
self.kind = kind
|
| 16 |
+
self.__dict__.update(kwargs)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _to_dict(rec) -> dict:
|
| 20 |
+
if hasattr(rec, "model_dump"):
|
| 21 |
+
return rec.model_dump()
|
| 22 |
+
if hasattr(rec, "dict"):
|
| 23 |
+
return rec.dict()
|
| 24 |
+
return rec
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def find_last_recommendations(conversation) -> List[Dict[str, Any]]:
|
| 28 |
+
"""Scans the raw conversation (newest first) for the last Agent turn that
|
| 29 |
+
carried a non-empty recommendations list. Requires the frontend to echo
|
| 30 |
+
`recommendations` back on Agent turns (see schemas.Turn / main.py) — this is
|
| 31 |
+
what lets a 'confirmation' turn reuse the REAL prior shortlist instead of the
|
| 32 |
+
model having to recall or reconstruct it from plain text."""
|
| 33 |
+
for turn in reversed(conversation):
|
| 34 |
+
if turn.role.lower() == "agent" and getattr(turn, "recommendations", None):
|
| 35 |
+
return [_to_dict(r) for r in turn.recommendations]
|
| 36 |
+
return []
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _last_agent_message(conversation):
|
| 40 |
+
for turn in reversed(conversation):
|
| 41 |
+
if turn.role.lower() == "agent":
|
| 42 |
+
return turn.content.strip()
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def decide(state: dict, conversation) -> Action:
|
| 47 |
+
if not state.get("in_scope", True):
|
| 48 |
+
return Action("off_topic")
|
| 49 |
+
|
| 50 |
+
intent = state.get("intent", "new_request")
|
| 51 |
+
prior_recs = find_last_recommendations(conversation)
|
| 52 |
+
|
| 53 |
+
if intent == "off_topic":
|
| 54 |
+
return Action("off_topic")
|
| 55 |
+
|
| 56 |
+
if intent == "comparison_question":
|
| 57 |
+
return Action("compare", compared_tests=state.get("compared_tests", []),
|
| 58 |
+
topic_keywords=state.get("compared_tests", []) or state.get("topic_keywords", []))
|
| 59 |
+
|
| 60 |
+
if intent == "confirmation":
|
| 61 |
+
if prior_recs:
|
| 62 |
+
return Action("close", reuse_recommendations=prior_recs)
|
| 63 |
+
# Nothing to confirm yet (e.g. "thanks" mid-clarification) — keep going.
|
| 64 |
+
intent = "new_request"
|
| 65 |
+
|
| 66 |
+
if intent == "pushback_feedback":
|
| 67 |
+
return Action(
|
| 68 |
+
"redirect",
|
| 69 |
+
role_summary=state.get("role_summary"),
|
| 70 |
+
purpose=state.get("purpose"),
|
| 71 |
+
topic_keywords=state.get("topic_keywords", []),
|
| 72 |
+
prior_recommendations=prior_recs,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# --- Smart Update Detection ---
|
| 76 |
+
# If we already have prior recommendations, any new requirement is an update to
|
| 77 |
+
# the existing list, even if the NLU missed the strict 'addition_removal' intent.
|
| 78 |
+
updating_flag = False
|
| 79 |
+
if intent == "addition_removal":
|
| 80 |
+
updating_flag = True
|
| 81 |
+
elif intent in ["new_request", "clarifying_answer"] and len(prior_recs) > 0:
|
| 82 |
+
updating_flag = True
|
| 83 |
+
|
| 84 |
+
# --- THE AMNESIA & LOOP FIX ---
|
| 85 |
+
# If we already have a shortlist from the conversation history, we know we are ready to recommend.
|
| 86 |
+
# This overrides the NLU if it "forgets" the role summary mid-conversation.
|
| 87 |
+
if updating_flag and prior_recs:
|
| 88 |
+
ready = True
|
| 89 |
+
elif intent == "clarifying_answer":
|
| 90 |
+
# If the user just gave us more info, stop asking and just recommend!
|
| 91 |
+
ready = True
|
| 92 |
+
else:
|
| 93 |
+
ready = bool(state.get("ready_to_recommend")) and bool(state.get("role_summary"))
|
| 94 |
+
|
| 95 |
+
candidate_question = (
|
| 96 |
+
state.get("missing_info_question")
|
| 97 |
+
or "Could you tell me more about the role and the purpose of this assessment?"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# LOOP-BREAKER: if the question we're about to ask is the same one already
|
| 101 |
+
# asked last turn, the NLU stage isn't making progress on it.
|
| 102 |
+
# Proceed with whatever is known instead of asking a third time.
|
| 103 |
+
if not ready:
|
| 104 |
+
last_agent_msg = _last_agent_message(conversation)
|
| 105 |
+
if last_agent_msg and candidate_question.strip().lower() == last_agent_msg.strip().lower():
|
| 106 |
+
ready = bool(state.get("role_summary")) # proceed if we at least know who this is for
|
| 107 |
+
|
| 108 |
+
if ready:
|
| 109 |
+
return Action("recommend", updating=updating_flag, prior_recommendations=prior_recs,
|
| 110 |
+
topic_keywords=state.get("topic_keywords", []))
|
| 111 |
+
|
| 112 |
+
return Action("ask_question", question=candidate_question)
|
app/Responder.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_openai import ChatOpenAI
|
| 2 |
+
from langchain_core.prompts import PromptTemplate
|
| 3 |
+
from langchain_core.output_parsers import JsonOutputParser
|
| 4 |
+
from app.schemas import ChatResponse
|
| 5 |
+
from app.config import HUGGINGFACEHUB_API_TOKEN
|
| 6 |
+
from app.Llm_utils import invoke_with_retry, UpstreamUnavailableError
|
| 7 |
+
|
| 8 |
+
class ResponderStage:
|
| 9 |
+
"""Stage 3: Phraser stage. Uses the specific action kind
|
| 10 |
+
determined by the deterministic planner, context data, and real catalog
|
| 11 |
+
candidates to compile a highly contextual response.
|
| 12 |
+
"""
|
| 13 |
+
def __init__(self):
|
| 14 |
+
# Upgraded to Llama-3.1-8B-Instruct
|
| 15 |
+
self.llm = ChatOpenAI(
|
| 16 |
+
model="meta-llama/Llama-3.1-8B-Instruct:novita",
|
| 17 |
+
api_key=HUGGINGFACEHUB_API_TOKEN,
|
| 18 |
+
base_url="https://router.huggingface.co/v1",
|
| 19 |
+
max_tokens=700,
|
| 20 |
+
temperature=0.2,
|
| 21 |
+
)
|
| 22 |
+
self.parser = JsonOutputParser(pydantic_object=ChatResponse)
|
| 23 |
+
self.prompt = PromptTemplate(
|
| 24 |
+
template="""You are an expert SHL Assessment Consultant writing ONE highly tailored response turn. A planning step has already decided the strategy — your only job is to execute the phrasing naturally based on the catalog data provided below.
|
| 25 |
+
|
| 26 |
+
Action to perform: {action}
|
| 27 |
+
- "recommend": Present or update an assessment shortlist.
|
| 28 |
+
- "compare": Explicitly explain structural differences between specific tests requested by the user. Do not attach a recommendations array.
|
| 29 |
+
- "redirect": Address user constraints or handle legal/regulatory compliance pushbacks.
|
| 30 |
+
|
| 31 |
+
Context about the request:
|
| 32 |
+
- Target Audience: {role_summary}
|
| 33 |
+
- Purpose: {purpose}
|
| 34 |
+
- Updating a prior list: {updating}
|
| 35 |
+
- Prior shortlist (if updating/redirecting): {prior_recommendations}
|
| 36 |
+
|
| 37 |
+
Real catalog candidates available (Choose and discuss ONLY from this list):
|
| 38 |
+
{candidates}
|
| 39 |
+
|
| 40 |
+
Conversation so far:
|
| 41 |
+
{history}
|
| 42 |
+
|
| 43 |
+
Latest User Message:
|
| 44 |
+
{input}
|
| 45 |
+
|
| 46 |
+
CRITICAL LEGAL GUARDRAIL:
|
| 47 |
+
If the Latest User Message asks whether an assessment is legally required, legal obligations under laws like HIPAA, or whether an SHL test legally satisfies a compliance mandate, you MUST explicitly refuse to give legal advice. State clearly that legal compliance and regulatory obligations are outside what you can advise on, and direct them to their legal or compliance team. You can only confirm what the test measures, not its legal sufficiency.
|
| 48 |
+
|
| 49 |
+
RULES:
|
| 50 |
+
1. Speak like a senior human consultant. Cut out all robotic filler text. Jump straight into the logic and expertise.
|
| 51 |
+
2. Account for catalog constraints explicitly. Call out language limitations if requested tests don't match the requested language.
|
| 52 |
+
3. UPDATING A SHORTLIST: If "Updating a prior list" is True and you have a "Prior shortlist", you MUST physically copy every valid test from the prior shortlist into your new JSON "recommendations" array. Then, ADD the new tests from the candidates list. Do NOT drop the old tests unless requested.
|
| 53 |
+
4. NEVER leak raw JSON arrays, Python dictionaries, or brackets into the "reply" prose.
|
| 54 |
+
5. THE "RECOMMEND + REFINE" STRATEGY: If you are providing a shortlist but feel a specific detail (like seniority, spoken language, or cognitive needs) is missing, provide the preliminary recommendations AND end your text reply by asking the user a specific question to narrow it down further.
|
| 55 |
+
6. Set "end_of_conversation" to false.
|
| 56 |
+
|
| 57 |
+
Respond with ONLY the JSON object matching this schema:
|
| 58 |
+
{format_instructions}""",
|
| 59 |
+
input_variables=["action", "role_summary", "purpose", "updating",
|
| 60 |
+
"prior_recommendations", "candidates", "history", "input"],
|
| 61 |
+
partial_variables={"format_instructions": self.parser.get_format_instructions()},
|
| 62 |
+
)
|
| 63 |
+
self.chain = self.prompt | self.llm | self.parser
|
| 64 |
+
|
| 65 |
+
def run(self, **kwargs) -> dict:
|
| 66 |
+
try:
|
| 67 |
+
return invoke_with_retry(self.chain, kwargs)
|
| 68 |
+
except UpstreamUnavailableError as e:
|
| 69 |
+
print(f"[Responder] Upstream failure: {e}")
|
| 70 |
+
return {
|
| 71 |
+
"reply": "I'm having trouble reaching the assessment engine right now — could you try again in a moment?",
|
| 72 |
+
"recommendations": [],
|
| 73 |
+
"end_of_conversation": False,
|
| 74 |
+
}
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"[Responder] Parse failure: {e}")
|
| 77 |
+
return {
|
| 78 |
+
"reply": "I ran into an issue formatting that recommendation — could you rephrase your last message?",
|
| 79 |
+
"recommendations": [],
|
| 80 |
+
"end_of_conversation": False,
|
| 81 |
+
}
|
app/agent.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.Nlu import NLUStage
|
| 2 |
+
from app.Planner import decide
|
| 3 |
+
from app.Responder import ResponderStage
|
| 4 |
+
from app.database import db_manager
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SHLAgent:
|
| 8 |
+
"""Orchestrates the 3-stage pipeline for one turn:
|
| 9 |
+
|
| 10 |
+
1. NLU (LLM) -> structured facts about the conversation
|
| 11 |
+
2. Planner (Python) -> deterministic decision of what to do next
|
| 12 |
+
3. Responder (LLM) -> phrasing, ONLY when the action needs one
|
| 13 |
+
(off_topic / ask_question / close never call the LLM
|
| 14 |
+
at all, so they can never hallucinate)
|
| 15 |
+
|
| 16 |
+
Recommendations returned by the Responder are validated against the actual
|
| 17 |
+
retrieved catalog candidates before being returned to the API layer — any
|
| 18 |
+
name/url the model invents that isn't in the candidate set is dropped.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.nlu = NLUStage()
|
| 23 |
+
self.responder = ResponderStage()
|
| 24 |
+
|
| 25 |
+
def handle_conversation(self, conversation: list) -> dict:
|
| 26 |
+
latest = conversation[-1].content
|
| 27 |
+
history_str = "\n".join(f"{t.role.capitalize()}: {t.content}" for t in conversation[:-1])
|
| 28 |
+
|
| 29 |
+
state = self.nlu.run(history=history_str, latest_message=latest)
|
| 30 |
+
|
| 31 |
+
# If the NLU call failed due to the LLM provider itself being unavailable
|
| 32 |
+
# (not a real extraction gap), say so honestly instead of asking a
|
| 33 |
+
# clarifying question the user already answered.
|
| 34 |
+
if state.get("_upstream_failure"):
|
| 35 |
+
return {
|
| 36 |
+
"reply": "I'm having trouble reaching the assessment engine right now — could you try again in a moment?",
|
| 37 |
+
"recommendations": [],
|
| 38 |
+
"end_of_conversation": False,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
action = decide(state, conversation)
|
| 42 |
+
|
| 43 |
+
# --- No-LLM paths: cannot hallucinate by construction ---
|
| 44 |
+
if action.kind == "off_topic":
|
| 45 |
+
return {
|
| 46 |
+
"reply": "I'm focused on helping with SHL assessment recommendations for hiring — happy to help once you've got a role, skill, or candidate pool in mind!",
|
| 47 |
+
"recommendations": [],
|
| 48 |
+
"end_of_conversation": False,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if action.kind == "ask_question":
|
| 52 |
+
return {
|
| 53 |
+
"reply": action.question,
|
| 54 |
+
"recommendations": [],
|
| 55 |
+
"end_of_conversation": False,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if action.kind == "close":
|
| 59 |
+
return {
|
| 60 |
+
"reply": "Great — glad that fits. Locking in this shortlist.",
|
| 61 |
+
"recommendations": getattr(action, "reuse_recommendations", []),
|
| 62 |
+
"end_of_conversation": True,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# --- LLM path: compare / recommend / redirect, all need retrieval first ---
|
| 66 |
+
keywords = getattr(action, "topic_keywords", state.get("topic_keywords", []))
|
| 67 |
+
|
| 68 |
+
# FIX: If we are updating an existing list, focus the vector search STRICTLY
|
| 69 |
+
# on the newest message so the new test isn't drowned out by old keywords.
|
| 70 |
+
if getattr(action, "updating", False):
|
| 71 |
+
query = latest
|
| 72 |
+
else:
|
| 73 |
+
query = " ".join(keywords) if keywords else latest
|
| 74 |
+
|
| 75 |
+
candidates = db_manager.query_catalog_structured(query, n_results=8)
|
| 76 |
+
candidates_str = "\n".join(
|
| 77 |
+
f"- {c['name']} | {c['test_type']} | {c['url']}" for c in candidates
|
| 78 |
+
) or "No close matches found in catalog."
|
| 79 |
+
|
| 80 |
+
result = self.responder.run(
|
| 81 |
+
action=action.kind, # "compare" | "recommend" | "redirect"
|
| 82 |
+
role_summary=state.get("role_summary") or "not specified",
|
| 83 |
+
purpose=state.get("purpose") or "not specified",
|
| 84 |
+
updating=getattr(action, "updating", False),
|
| 85 |
+
prior_recommendations=getattr(action, "prior_recommendations", []),
|
| 86 |
+
candidates=candidates_str,
|
| 87 |
+
history=history_str,
|
| 88 |
+
input=latest,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# --- Validation ---
|
| 92 |
+
# Combine the fresh candidates with the prior recommendations to create the allowed whitelist
|
| 93 |
+
prior_recs = getattr(action, "prior_recommendations", [])
|
| 94 |
+
allowed_tests = candidates.copy()
|
| 95 |
+
allowed_urls = {c["url"] for c in allowed_tests}
|
| 96 |
+
|
| 97 |
+
for pr in prior_recs:
|
| 98 |
+
if isinstance(pr, dict) and pr.get("url") not in allowed_urls:
|
| 99 |
+
allowed_tests.append(pr)
|
| 100 |
+
allowed_urls.add(pr["url"])
|
| 101 |
+
|
| 102 |
+
# Build validation dictionaries from the combined whitelist
|
| 103 |
+
valid_by_url = {c["url"]: c for c in allowed_tests}
|
| 104 |
+
valid_by_name = {c["name"].strip().lower(): c for c in allowed_tests}
|
| 105 |
+
|
| 106 |
+
def _resolve(rec):
|
| 107 |
+
if isinstance(rec, dict):
|
| 108 |
+
url = rec.get("url")
|
| 109 |
+
if url in valid_by_url:
|
| 110 |
+
return valid_by_url[url]
|
| 111 |
+
name = (rec.get("name") or "").strip().lower()
|
| 112 |
+
return valid_by_name.get(name)
|
| 113 |
+
if isinstance(rec, str):
|
| 114 |
+
return valid_by_name.get(rec.strip().lower())
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
resolved = [_resolve(r) for r in result.get("recommendations", [])]
|
| 118 |
+
|
| 119 |
+
# Clean the final list and deduplicate (in case the LLM added a test twice)
|
| 120 |
+
seen_urls = set()
|
| 121 |
+
final_recs = []
|
| 122 |
+
for r in resolved:
|
| 123 |
+
if r is not None and r["url"] not in seen_urls:
|
| 124 |
+
final_recs.append(r)
|
| 125 |
+
seen_urls.add(r["url"])
|
| 126 |
+
|
| 127 |
+
result["recommendations"] = final_recs
|
| 128 |
+
result["end_of_conversation"] = False # closing only ever happens via the planner's "close" path
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Global singleton instance
|
| 133 |
+
shl_agent = SHLAgent()
|
app/config.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
# Load variables from .env file
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
HUGGINGFACEHUB_API_TOKEN = os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 8 |
+
|
| 9 |
+
if not HUGGINGFACEHUB_API_TOKEN:
|
| 10 |
+
raise ValueError("CRITICAL: HUGGINGFACEHUB_API_TOKEN is missing from your environment or .env file.")
|
app/database.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import chromadb
|
| 4 |
+
|
| 5 |
+
class VectorDBManager:
|
| 6 |
+
def __init__(self, collection_name: str = "shl_catalog"):
|
| 7 |
+
# Persistent storage locally, fits stateless server startups perfectly
|
| 8 |
+
self.client = chromadb.Client()
|
| 9 |
+
self.collection = self.client.get_or_create_collection(name=collection_name)
|
| 10 |
+
|
| 11 |
+
def initialize_catalog(self, catalog_path: str):
|
| 12 |
+
"""Loads and indexes the catalog JSON file if the collection is empty."""
|
| 13 |
+
if self.collection.count() > 0:
|
| 14 |
+
return # Already populated
|
| 15 |
+
|
| 16 |
+
if not os.path.exists(catalog_path):
|
| 17 |
+
raise FileNotFoundError(f"Catalog file not found at: {catalog_path}")
|
| 18 |
+
|
| 19 |
+
# Added utf-8 encoding and strict=False to bypass dirty JSON characters
|
| 20 |
+
with open(catalog_path, "r", encoding="utf-8") as f:
|
| 21 |
+
catalog = json.load(f, strict=False)
|
| 22 |
+
|
| 23 |
+
docs = []
|
| 24 |
+
metadatas = []
|
| 25 |
+
ids = []
|
| 26 |
+
|
| 27 |
+
for item in catalog:
|
| 28 |
+
# Combine content for meaningful semantic retrieval
|
| 29 |
+
text_chunk = (
|
| 30 |
+
f"Name: {item['name']}. "
|
| 31 |
+
f"Description: {item['description']}. "
|
| 32 |
+
f"Job Levels: {', '.join(item.get('job_levels', []))}"
|
| 33 |
+
)
|
| 34 |
+
docs.append(text_chunk)
|
| 35 |
+
|
| 36 |
+
# Metadata aligns precisely with the output Recommendation model
|
| 37 |
+
metadatas.append({
|
| 38 |
+
"name": item["name"],
|
| 39 |
+
"url": item.get("link", ""),
|
| 40 |
+
"test_type": item.get("keys", ["General"])[0] if item.get("keys") else "General"
|
| 41 |
+
})
|
| 42 |
+
ids.append(str(item["entity_id"]))
|
| 43 |
+
|
| 44 |
+
self.collection.add(documents=docs, metadatas=metadatas, ids=ids)
|
| 45 |
+
|
| 46 |
+
def query_catalog(self, query_text: str, n_results: int = 8) -> str:
|
| 47 |
+
"""Queries the collection and returns a structured string for prompt context.
|
| 48 |
+
Kept for backward compatibility — prefer query_catalog_structured for new code,
|
| 49 |
+
since the structured form is what enables post-generation validation."""
|
| 50 |
+
results = self.collection.query(query_texts=[query_text], n_results=n_results)
|
| 51 |
+
|
| 52 |
+
context_str = ""
|
| 53 |
+
if results['documents'] and results['documents'][0]:
|
| 54 |
+
for i, doc in enumerate(results['documents'][0]):
|
| 55 |
+
meta = results['metadatas'][0][i]
|
| 56 |
+
context_str += f"- {doc} (URL: {meta['url']}, Type: {meta['test_type']})\n"
|
| 57 |
+
return context_str
|
| 58 |
+
|
| 59 |
+
def query_catalog_structured(self, query_text: str, n_results: int = 8) -> list:
|
| 60 |
+
"""Same query, returned as a list of dicts (name/url/test_type/description)
|
| 61 |
+
instead of a flattened string. This is the ground truth used both to build
|
| 62 |
+
the responder's candidate list AND to validate its output afterward —
|
| 63 |
+
anything the model returns that isn't in this list gets dropped."""
|
| 64 |
+
results = self.collection.query(query_texts=[query_text], n_results=n_results)
|
| 65 |
+
|
| 66 |
+
candidates = []
|
| 67 |
+
if results['documents'] and results['documents'][0]:
|
| 68 |
+
for i, doc in enumerate(results['documents'][0]):
|
| 69 |
+
meta = results['metadatas'][0][i]
|
| 70 |
+
candidates.append({
|
| 71 |
+
"name": meta["name"],
|
| 72 |
+
"url": meta["url"],
|
| 73 |
+
"test_type": meta["test_type"],
|
| 74 |
+
"description": doc,
|
| 75 |
+
})
|
| 76 |
+
return candidates
|
| 77 |
+
|
| 78 |
+
# Global singleton instance
|
| 79 |
+
db_manager = VectorDBManager()
|
app/main.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.responses import HTMLResponse
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from app.schemas import ChatRequest, ChatResponse
|
| 5 |
+
from app.database import db_manager
|
| 6 |
+
from app.agent import shl_agent
|
| 7 |
+
|
| 8 |
+
@asynccontextmanager
|
| 9 |
+
async def lifespan(app: FastAPI):
|
| 10 |
+
# Setup step executed before the API server accepts traffic
|
| 11 |
+
catalog_file_path = "shl_product_catalog.json"
|
| 12 |
+
db_manager.initialize_catalog(catalog_file_path)
|
| 13 |
+
yield
|
| 14 |
+
|
| 15 |
+
app = FastAPI(lifespan=lifespan)
|
| 16 |
+
|
| 17 |
+
# --- FRONTEND HTML & JS ---
|
| 18 |
+
html_content = """
|
| 19 |
+
<!DOCTYPE html>
|
| 20 |
+
<html>
|
| 21 |
+
<head>
|
| 22 |
+
<title>SHL Assessment Recommender</title>
|
| 23 |
+
<style>
|
| 24 |
+
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background-color: #f4f4f9; }
|
| 25 |
+
#chat-container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); height: 500px; overflow-y: auto; margin-bottom: 20px; }
|
| 26 |
+
.message { margin-bottom: 15px; padding: 10px; border-radius: 5px; }
|
| 27 |
+
.user { background-color: #e3f2fd; text-align: right; border-left: 4px solid #2196f3; }
|
| 28 |
+
.agent { background-color: #f1f8e9; text-align: left; border-left: 4px solid #4caf50; }
|
| 29 |
+
.system { text-align: center; color: #888; font-style: italic; font-size: 0.9em; }
|
| 30 |
+
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.9em; }
|
| 31 |
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
| 32 |
+
th { background-color: #4caf50; color: white; }
|
| 33 |
+
.input-area { display: flex; gap: 10px; }
|
| 34 |
+
input[type="text"] { flex-grow: 1; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
|
| 35 |
+
button { padding: 10px 20px; background-color: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
| 36 |
+
button:hover { background-color: #0b7dda; }
|
| 37 |
+
</style>
|
| 38 |
+
</head>
|
| 39 |
+
<body>
|
| 40 |
+
<h2>SHL Assessment Consultant</h2>
|
| 41 |
+
<div id="chat-container">
|
| 42 |
+
<div class="message system">Start a conversation. E.g., "I'm hiring a Java developer."</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="input-area">
|
| 46 |
+
<input type="text" id="user-input" placeholder="Type your message here..." onkeypress="if(event.key === 'Enter') sendMessage()">
|
| 47 |
+
<button onclick="sendMessage()">Send</button>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<script>
|
| 51 |
+
// CLIENT-SIDE MEMORY: this array holds the stateless conversation history.
|
| 52 |
+
// NOTE: Agent turns now also carry `recommendations`, mirroring the
|
| 53 |
+
// structured list the server returned for that turn. This is required
|
| 54 |
+
// for confirmation/"that works" turns to reuse the REAL prior shortlist
|
| 55 |
+
// instead of the model having to recall it from plain text.
|
| 56 |
+
let conversationHistory = [];
|
| 57 |
+
|
| 58 |
+
async function sendMessage() {
|
| 59 |
+
const inputField = document.getElementById('user-input');
|
| 60 |
+
const userText = inputField.value.trim();
|
| 61 |
+
if (!userText) return;
|
| 62 |
+
|
| 63 |
+
const chatContainer = document.getElementById('chat-container');
|
| 64 |
+
|
| 65 |
+
// 1. Add User Message to UI & History
|
| 66 |
+
chatContainer.innerHTML += `<div class="message user"><strong>You:</strong> ${userText}</div>`;
|
| 67 |
+
conversationHistory.push({ "role": "User", "content": userText });
|
| 68 |
+
inputField.value = '';
|
| 69 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 70 |
+
|
| 71 |
+
// 2. Add loading indicator
|
| 72 |
+
const loadingId = "loading-" + Date.now();
|
| 73 |
+
chatContainer.innerHTML += `<div id="${loadingId}" class="message system">Consultant is typing...</div>`;
|
| 74 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
// 3. Send stateless array to FastAPI backend
|
| 78 |
+
const response = await fetch('/chat', {
|
| 79 |
+
method: 'POST',
|
| 80 |
+
headers: { 'Content-Type': 'application/json' },
|
| 81 |
+
body: JSON.stringify({ conversation: conversationHistory })
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
const data = await response.json();
|
| 85 |
+
document.getElementById(loadingId).remove();
|
| 86 |
+
|
| 87 |
+
// 4. Format Agent Reply & Recommendations
|
| 88 |
+
let agentHtml = `<strong>Consultant:</strong> ${data.reply}`;
|
| 89 |
+
|
| 90 |
+
if (data.recommendations && data.recommendations.length > 0) {
|
| 91 |
+
agentHtml += `<table><tr><th>Test Name</th><th>Type</th><th>Link</th></tr>`;
|
| 92 |
+
data.recommendations.forEach(rec => {
|
| 93 |
+
agentHtml += `<tr>
|
| 94 |
+
<td>${rec.name}</td>
|
| 95 |
+
<td>${rec.test_type}</td>
|
| 96 |
+
<td><a href="${rec.url}" target="_blank">View</a></td>
|
| 97 |
+
</tr>`;
|
| 98 |
+
});
|
| 99 |
+
agentHtml += `</table>`;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Add Agent Message to UI & History (recommendations included!)
|
| 103 |
+
chatContainer.innerHTML += `<div class="message agent">${agentHtml}</div>`;
|
| 104 |
+
conversationHistory.push({
|
| 105 |
+
"role": "Agent",
|
| 106 |
+
"content": data.reply,
|
| 107 |
+
"recommendations": data.recommendations && data.recommendations.length > 0 ? data.recommendations : null
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
// 5. Handle Memory Wipe on Conversation End
|
| 111 |
+
if (data.end_of_conversation) {
|
| 112 |
+
chatContainer.innerHTML += `<div class="message system">--- Conversation Closed. Memory wiped for next session. ---</div>`;
|
| 113 |
+
conversationHistory = []; // Wipes the memory instantly
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
} catch (error) {
|
| 117 |
+
document.getElementById(loadingId).remove();
|
| 118 |
+
chatContainer.innerHTML += `<div class="message system" style="color: red;">Error connecting to server.</div>`;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 122 |
+
}
|
| 123 |
+
</script>
|
| 124 |
+
</body>
|
| 125 |
+
</html>
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
# --- ROUTES ---
|
| 129 |
+
@app.get("/", response_class=HTMLResponse)
|
| 130 |
+
async def get_frontend():
|
| 131 |
+
# Serves the UI directly at the root URL
|
| 132 |
+
return html_content
|
| 133 |
+
|
| 134 |
+
@app.get("/health")
|
| 135 |
+
def health_check():
|
| 136 |
+
return {"status": "ok"}
|
| 137 |
+
|
| 138 |
+
@app.post("/chat", response_model=ChatResponse)
|
| 139 |
+
def chat_endpoint(request: ChatRequest):
|
| 140 |
+
# All orchestration (NLU -> planner -> retrieval -> responder -> validation)
|
| 141 |
+
# now lives in app.agent.SHLAgent.handle_conversation. The endpoint itself
|
| 142 |
+
# is just I/O plus a safety net for unexpected pipeline failures.
|
| 143 |
+
try:
|
| 144 |
+
response_dict = shl_agent.handle_conversation(request.conversation)
|
| 145 |
+
return ChatResponse(**response_dict)
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
import traceback
|
| 149 |
+
traceback.print_exc()
|
| 150 |
+
return ChatResponse(
|
| 151 |
+
reply="I ran into an issue processing that — could you rephrase it?",
|
| 152 |
+
recommendations=[],
|
| 153 |
+
end_of_conversation=False
|
| 154 |
+
)
|
app/schemas.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
# --- Output Schemas (defined first: Turn needs Recommendation) ---
|
| 5 |
+
class Recommendation(BaseModel):
|
| 6 |
+
name: str = Field(description="Name of the assessment")
|
| 7 |
+
url: str = Field(description="The catalog link to the assessment")
|
| 8 |
+
test_type: str = Field(description="The Test Type from the catalog")
|
| 9 |
+
|
| 10 |
+
# --- Input Schemas ---
|
| 11 |
+
class Turn(BaseModel):
|
| 12 |
+
role: str
|
| 13 |
+
content: str
|
| 14 |
+
# Optional: the frontend should echo back the recommendations it received on
|
| 15 |
+
# each Agent turn. This lets the planner ground "confirmation" replies in the
|
| 16 |
+
# ACTUAL last shortlist shown to the user, instead of asking the LLM to recall
|
| 17 |
+
# or re-derive it from plain text (which is where hallucination crept in).
|
| 18 |
+
recommendations: Optional[List[Recommendation]] = None
|
| 19 |
+
|
| 20 |
+
class ChatRequest(BaseModel):
|
| 21 |
+
conversation: List[Turn]
|
| 22 |
+
|
| 23 |
+
class ChatResponse(BaseModel):
|
| 24 |
+
reply: str = Field(description="The conversational text spoken by the agent")
|
| 25 |
+
recommendations: List[Recommendation] = Field(
|
| 26 |
+
description="List of recommended assessments. Empty if asking clarifying questions."
|
| 27 |
+
)
|
| 28 |
+
end_of_conversation: bool = Field(description="True if the task is complete, False otherwise")
|
| 29 |
+
|
| 30 |
+
# --- Internal schema, used only by the NLU stage (not exposed via the API) ---
|
| 31 |
+
class ConversationState(BaseModel):
|
| 32 |
+
in_scope: bool = Field(
|
| 33 |
+
description="False only if the latest user message is clearly unrelated to hiring/SHL assessments (e.g. weather, small talk, jokes)."
|
| 34 |
+
)
|
| 35 |
+
intent: str = Field(
|
| 36 |
+
description="One of: new_request, clarifying_answer, comparison_question, confirmation, addition_removal, pushback_feedback, off_topic"
|
| 37 |
+
)
|
| 38 |
+
role_summary: Optional[str] = Field(default=None, description="Short description of who the assessment is for")
|
| 39 |
+
purpose: Optional[str] = Field(default=None, description="Selection vs. development, or other explicit purpose")
|
| 40 |
+
topic_keywords: List[str] = Field(default_factory=list, description="Skills/domain/role words for catalog search")
|
| 41 |
+
compared_tests: List[str] = Field(default_factory=list, description="Test names to compare, if intent is comparison_question")
|
| 42 |
+
missing_info_question: Optional[str] = Field(default=None, description="The single next clarifying question, if not ready to recommend")
|
| 43 |
+
ready_to_recommend: bool = Field(
|
| 44 |
+
description=(
|
| 45 |
+
"True ONLY if there is enough specific detail to pick the right catalog items with confidence — "
|
| 46 |
+
"this covers role/seniority AND any domain-critical detail that changes which test variant applies "
|
| 47 |
+
"(e.g. spoken language/accent for contact-centre or call-simulation roles, selection vs. development "
|
| 48 |
+
"purpose, programming language/stack for technical roles). False if any such detail is still open, "
|
| 49 |
+
"even if role_summary and purpose both look filled."
|
| 50 |
+
)
|
| 51 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
python-dotenv
|
| 5 |
+
langchain
|
| 6 |
+
langchain-community
|
| 7 |
+
langchain-huggingface
|
| 8 |
+
langchain-core
|
| 9 |
+
chromadb
|
| 10 |
+
sentence-transformers
|