SLSAGENT / eval /eval_harness.py
jarvisemitra
SHL Assessment Recommender - Full implementation with hybrid retrieval, slot-based conversation analysis, safety guards, and evaluation harness
ae3c639
Raw
History Blame Contribute Delete
8.04 kB
"""
Evaluation harness: measures Recall@10 and behavior probes against
the provided sample conversation traces.
This is what SHL reviewers care about most — measured, reproducible metrics.
"""
import json
import re
import httpx
import time
from pathlib import Path
BASE_URL = "http://localhost:8000"
TRACES_DIR = Path(__file__).parent.parent / "sample_conversations" / "GenAI_SampleConversations"
def parse_trace(filepath: Path) -> dict:
"""
Parse a conversation trace markdown file.
Extracts: user messages, expected recommendations (from last turn with table).
"""
text = filepath.read_text(encoding="utf-8")
# Extract all turns
turns = []
turn_blocks = re.split(r'###\s+Turn\s+\d+', text)
for block in turn_blocks[1:]: # Skip the header
user_match = re.search(r'\*\*User\*\*\s*\n\s*>\s*(.+?)(?:\n\n|\n\*\*)', block, re.DOTALL)
user_msg = ""
if user_match:
user_msg = user_match.group(1).strip()
# Clean up multi-line quotes
user_msg = re.sub(r'\n\s*>\s*', '\n', user_msg).strip()
turns.append({
"user_message": user_msg,
"block": block,
})
# Extract expected recommendations from the LAST table in the trace
expected_recs = []
table_pattern = re.compile(
r'\|\s*\d+\s*\|\s*(.+?)\s*\|\s*\S+\s*\|\s*.+?\s*\|\s*.+?\s*\|\s*.+?\s*\|\s*<?(https://www\.shl\.com/.+?)>?\s*\|',
re.MULTILINE,
)
for match in table_pattern.finditer(text):
name = match.group(1).strip()
url = match.group(2).strip()
expected_recs.append({"name": name, "url": url})
# Deduplicate — keep the latest set (from the final turn)
# Find the last turn that has a table
last_table_recs = []
for turn in reversed(turns):
table_matches = table_pattern.finditer(turn["block"])
recs_in_turn = []
for m in table_matches:
recs_in_turn.append({"name": m.group(1).strip(), "url": m.group(2).strip()})
if recs_in_turn:
last_table_recs = recs_in_turn
break
if last_table_recs:
expected_recs = last_table_recs
return {
"filename": filepath.name,
"turns": turns,
"expected_recommendations": expected_recs,
}
def run_conversation(trace: dict, base_url: str = BASE_URL) -> dict:
"""
Run a conversation against the agent using the trace's user messages.
Returns the agent's final recommendations and turn count.
"""
messages = []
last_response = None
turn_count = 0
client = httpx.Client(timeout=35.0)
for turn in trace["turns"]:
user_msg = turn["user_message"]
if not user_msg:
continue
messages.append({"role": "user", "content": user_msg})
try:
response = client.post(
f"{base_url}/chat",
json={"messages": messages},
)
response.raise_for_status()
last_response = response.json()
# Add assistant reply to history
messages.append({
"role": "assistant",
"content": last_response.get("reply", ""),
})
turn_count += 1
# Check if conversation is done
if last_response.get("end_of_conversation", False):
break
except Exception as e:
print(f" Error on turn {turn_count + 1}: {e}")
break
client.close()
return {
"turn_count": turn_count,
"final_response": last_response,
"final_recommendations": (
last_response.get("recommendations", []) if last_response else []
),
}
def compute_recall_at_k(
predicted: list[dict],
expected: list[dict],
k: int = 10,
) -> float:
"""
Compute Recall@K.
Recall@K = (number of relevant items in top K) / (total relevant items)
"""
if not expected:
return 1.0 # No expected items means we can't fail
expected_urls = {rec["url"] for rec in expected}
predicted_urls = {rec.get("url", "") for rec in predicted[:k]}
hits = len(expected_urls & predicted_urls)
recall = hits / len(expected_urls)
return recall
def check_schema_compliance(response: dict) -> bool:
"""Check if a response matches the required schema."""
if not isinstance(response, dict):
return False
required_fields = ["reply", "recommendations", "end_of_conversation"]
for field in required_fields:
if field not in response:
return False
if not isinstance(response["reply"], str):
return False
if not isinstance(response["recommendations"], list):
return False
for rec in response["recommendations"]:
if not isinstance(rec, dict):
return False
if "name" not in rec or "url" not in rec or "test_type" not in rec:
return False
if not isinstance(response["end_of_conversation"], bool):
return False
return True
def run_full_evaluation(base_url: str = BASE_URL):
"""Run the full evaluation suite and print results."""
print("=" * 60)
print("SHL Assessment Recommender — Evaluation Report")
print("=" * 60)
# Check health
try:
client = httpx.Client(timeout=120.0)
health = client.get(f"{base_url}/health")
print(f"\n✓ Health check: {health.status_code}{health.json()}")
client.close()
except Exception as e:
print(f"\n✗ Health check failed: {e}")
return
# Find all trace files
trace_files = sorted(TRACES_DIR.glob("C*.md"))
if not trace_files:
print(f"\n✗ No trace files found in {TRACES_DIR}")
return
print(f"\nFound {len(trace_files)} conversation traces\n")
results = []
total_recall = 0.0
total_turns = 0
schema_passes = 0
for trace_file in trace_files:
trace = parse_trace(trace_file)
print(f"--- {trace['filename']} ---")
print(f" Expected: {len(trace['expected_recommendations'])} assessments")
result = run_conversation(trace, base_url)
# Schema compliance
schema_ok = check_schema_compliance(result["final_response"]) if result["final_response"] else False
if schema_ok:
schema_passes += 1
# Recall@10
recall = compute_recall_at_k(
result["final_recommendations"],
trace["expected_recommendations"],
k=10,
)
total_recall += recall
total_turns += result["turn_count"]
print(f" Turns: {result['turn_count']}")
print(f" Recommendations: {len(result['final_recommendations'])}")
print(f" Recall@10: {recall:.2f}")
print(f" Schema OK: {'✓' if schema_ok else '✗'}")
# Print predicted vs expected
if result["final_recommendations"]:
pred_names = [r.get("name", "?") for r in result["final_recommendations"]]
print(f" Predicted: {pred_names}")
exp_names = [r["name"] for r in trace["expected_recommendations"]]
print(f" Expected: {exp_names}")
print()
results.append({
"trace": trace["filename"],
"recall": recall,
"turns": result["turn_count"],
"schema_ok": schema_ok,
})
# Summary
n = len(results)
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"Mean Recall@10: {total_recall / n:.3f}")
print(f"Avg Turns: {total_turns / n:.1f}")
print(f"Schema Compliance: {schema_passes}/{n} ({schema_passes/n*100:.0f}%)")
print(f"Total Traces: {n}")
print("=" * 60)
if __name__ == "__main__":
run_full_evaluation()