RapidOrc121 commited on
Commit
7488b2f
Β·
0 Parent(s):

Deploy SHL assessment recommender

Browse files
Files changed (11) hide show
  1. .gitattributes +1 -0
  2. .gitignore +19 -0
  3. Dockerfile +13 -0
  4. README.md +13 -0
  5. data/catalog.index +3 -0
  6. data/catalog_meta.json +1 -0
  7. main.py +45 -0
  8. models.py +23 -0
  9. requirements.txt +25 -0
  10. shl_product_catalog.json +0 -0
  11. 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()