cloud450 commited on
Commit
ba18317
·
verified ·
1 Parent(s): ae6193f

Upload 16 files

Browse files
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ .DS_Store
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .venv/
12
+ venv/
13
+ *.log
README.md CHANGED
@@ -1,12 +1,88 @@
1
  ---
2
- title: Coderound Bkl
3
- emoji: 🌍
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Recruitment Agent
3
+ emoji:
4
+ colorFrom: indigo
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "4.44.0"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # AI Recruitment Agent
13
+
14
+ A production-grade hybrid candidate matching pipeline using **Groq LLM**, **Pinecone vector DB**, and a **Gradio** UI.
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ CSV Input → Stage 1: Normalize (Groq)
20
+ → Stage 2: Embed + Match (Pinecone + SentenceTransformers) → Top 20
21
+ → Stage 3: Deterministic Rerank (Groq) → Top 10
22
+ → Stage 4: LLM Deep Review (Groq) → Top 5
23
+ → Stage 5: Final Synthesis (Groq) → Shortlist
24
+ ```
25
+
26
+ ## Setup (Local)
27
+
28
+ ### 1. Install dependencies
29
+
30
+ ```bash
31
+ pip install -r requirements.txt
32
+ ```
33
+
34
+ ### 2. Configure environment
35
+
36
+ ```bash
37
+ cp .env.example .env
38
+ # Edit .env and fill in your API keys
39
+ ```
40
+
41
+ ### 3. Create Pinecone index
42
+
43
+ In your Pinecone console:
44
+ - Create an index named `recruitment-index` (or whatever you set in `PINECONE_INDEX`)
45
+ - Dimension: **384** for `all-MiniLM-L6-v2`, **1024** for `BAAI/bge-m3`
46
+ - Metric: **cosine**
47
+
48
+ ### 4. Run
49
+
50
+ ```bash
51
+ python app.py
52
+ ```
53
+
54
+ Open http://localhost:7860
55
+
56
+ ## Setup (Hugging Face Spaces)
57
+
58
+ Do **not** commit a `.env` file. Instead, go to your Space → **Settings → Repository Secrets** and add:
59
+
60
+ | Secret | Example value |
61
+ |--------|--------------|
62
+ | `GROQ_API_KEYS` | `gsk_xxx,gsk_yyy` |
63
+ | `GROQ_MODEL` | `llama3-70b-8192` |
64
+ | `PINECONE_API_KEY` | `pcsk_xxx` |
65
+ | `PINECONE_INDEX` | `recruitment-index` |
66
+ | `EMBEDDING_MODEL` | `all-MiniLM-L6-v2` |
67
+ | `STAGE2_TOP_K` | `20` |
68
+
69
+ ## CSV Format
70
+
71
+ | Column | Variants accepted |
72
+ |--------|----------|
73
+ | `name` | `full_name`, `candidate_name` |
74
+ | `email` | `email_address` |
75
+ | `skills` | `parsed_skills`, `technical_skills` |
76
+ | `experience` | `parsed_work_experience`, `years_of_experience` |
77
+ | `education` | `parsed_metadata_education` |
78
+ | `resume_text` | `parsed_summary`, `summary` |
79
+
80
+ ## Pipeline Stages
81
+
82
+ | Stage | Method | Input | Output |
83
+ |-------|--------|-------|--------|
84
+ | 1. Normalize | Groq LLM | All candidates | Structured features |
85
+ | 2. Embed & Match | Pinecone + SentenceTransformers | All candidates | Top 20 by similarity |
86
+ | 3. Rerank | Groq LLM (deterministic scoring) | Top 20 | Top 10 with scores |
87
+ | 4. Deep Review | Groq LLM | Top 5 | Verdicts + signals |
88
+ | 5. Final Synthesis | Groq LLM | Top 5 reviews | Final ranked shortlist |
ai-recruitment-agent.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:13f11747ae5b006135d2384c2f2c204c2513fcce08fb01771ba202b0398fb2d7
3
+ size 20134
app.py ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Recruitment Matching Agent — Gradio 4.16.0 UI
3
+ Run: python gradio_app.py
4
+ """
5
+
6
+ import os
7
+ import asyncio
8
+ import uuid
9
+ import io
10
+ import json
11
+ import threading
12
+ from typing import List, Optional
13
+ from dotenv import load_dotenv
14
+
15
+ load_dotenv()
16
+
17
+ import pandas as pd
18
+ import gradio as gr
19
+
20
+ from app.models.schemas import Candidate, EvaluationResponse
21
+ from app.services.evaluation_service import perform_hybrid_evaluation
22
+
23
+ # ─────────────────────────────────────────────────────────────────────────────
24
+ # Helpers
25
+ # ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ VERDICT_EMOJI = {
28
+ "strong hire": "🟢",
29
+ "hire": "🟡",
30
+ "consider": "🟠",
31
+ "reject": "🔴",
32
+ }
33
+
34
+ DECISION_COLOR = {
35
+ "strong hire": "#22c55e",
36
+ "hire": "#eab308",
37
+ "consider": "#f97316",
38
+ "reject": "#ef4444",
39
+ }
40
+
41
+ SAMPLE_JD = """Backend Engineer — SaaS Platform
42
+
43
+ We are seeking a Backend Engineer to design and build the core infrastructure of our SaaS platform. The role involves developing scalable microservices, building APIs, and managing IoT data pipelines.
44
+
45
+ Core Requirements:
46
+ - Minimum 3 years of experience in backend development
47
+ - Strong proficiency in Node.js
48
+ - Experience with FastAPI, Django, or Express
49
+ - Strong understanding of RESTful APIs and microservices
50
+ - Experience with relational and/or NoSQL databases
51
+
52
+ Preferred:
53
+ - Experience with AWS, GCP, or Azure
54
+ - Docker, Kubernetes, CI/CD pipelines
55
+ - Redis, Kafka or RabbitMQ
56
+ - Startup experience
57
+
58
+ Skills: Backend Engineer, Node.js, AWS, Microservices, IoT, SaaS, Serverless, API Development"""
59
+
60
+
61
+ def parse_csv_to_candidates(filepath: str) -> tuple[List[Candidate], pd.DataFrame, str]:
62
+ """Parse uploaded CSV into Candidate objects. Returns (candidates, df, error)."""
63
+ try:
64
+ df = pd.read_csv(filepath).fillna("")
65
+ candidates = []
66
+
67
+ # Smart column detection
68
+ col_map = {col.lower().strip(): col for col in df.columns}
69
+
70
+ def get_col(candidates_list):
71
+ for c in candidates_list:
72
+ if c in col_map:
73
+ return col_map[c]
74
+ return None
75
+
76
+ name_col = get_col(["name", "full_name", "candidate_name"])
77
+ email_col = get_col(["email", "email_address"])
78
+ skills_col = get_col(["skills", "parsed_skills", "technical_skills"])
79
+ exp_col = get_col(["experience", "parsed_work_experience", "work_experience", "years_of_experience"])
80
+ proj_col = get_col(["projects", "parsed_projects"])
81
+ edu_col = get_col(["education", "parsed_metadata_education", "education_status"])
82
+ resume_col = get_col(["resume_text", "parsed_summary", "summary", "resume"])
83
+
84
+ for _, row in df.iterrows():
85
+ candidates.append(Candidate(
86
+ id=str(uuid.uuid4()),
87
+ name=str(row[name_col]) if name_col else "Unknown",
88
+ email=str(row[email_col]) if email_col else "",
89
+ skills=str(row[skills_col]) if skills_col else "",
90
+ experience=str(row[exp_col]) if exp_col else "",
91
+ projects=str(row[proj_col]) if proj_col else "",
92
+ education=str(row[edu_col]) if edu_col else "",
93
+ resume_text=str(row[resume_col]) if resume_col else "",
94
+ ))
95
+
96
+ return candidates, df, ""
97
+ except Exception as e:
98
+ return [], pd.DataFrame(), f"Error parsing CSV: {e}"
99
+
100
+
101
+ def build_shortlist_table(response: EvaluationResponse) -> pd.DataFrame:
102
+ rows = []
103
+ for rank in response.shortlist:
104
+ detail = response.details.get(rank.candidate_id, {})
105
+ emoji = VERDICT_EMOJI.get(rank.decision.lower(), "⚪")
106
+ rows.append({
107
+ "Rank": rank.rank,
108
+ "Name": rank.name,
109
+ "Decision": f"{emoji} {rank.decision.title()}",
110
+ "Confidence": f"{int(detail.get('confidence', 0) * 100)}%",
111
+ "Why": rank.reason,
112
+ "Strengths": " | ".join(detail.get("strengths", [])),
113
+ "Risks": " | ".join(detail.get("risks", [])),
114
+ "Signal": detail.get("hidden_signal", ""),
115
+ })
116
+ return pd.DataFrame(rows)
117
+
118
+
119
+ def build_detail_md(response: EvaluationResponse, shortlist_df: pd.DataFrame) -> str:
120
+ md_parts = []
121
+ for rank in response.shortlist:
122
+ detail = response.details.get(rank.candidate_id, {})
123
+ emoji = VERDICT_EMOJI.get((detail.get("verdict") or rank.decision).lower(), "⚪")
124
+ verdict = (detail.get("verdict") or rank.decision).title()
125
+ confidence_pct = int(detail.get("confidence", 0) * 100)
126
+
127
+ md_parts.append(f"""
128
+ ### {rank.rank}. {rank.name} {emoji} {verdict}
129
+
130
+ **Why:** {detail.get("why", rank.reason)}
131
+
132
+ **Confidence:** {confidence_pct}%
133
+
134
+ **Strengths:**
135
+ {chr(10).join(f"- {s}" for s in detail.get("strengths", []))}
136
+
137
+ **Risks:**
138
+ {chr(10).join(f"- {r}" for r in detail.get("risks", []))}
139
+
140
+ **Hidden Signal:** _{detail.get("hidden_signal", "—")}_
141
+
142
+ ---
143
+ """)
144
+ return "\n".join(md_parts) if md_parts else "_No results yet._"
145
+
146
+
147
+ # ─────────────────────────────────────────────────────────────────────────────
148
+ # Core async runner
149
+ # ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ def run_evaluation_sync(jd: str, candidates: List[Candidate], log_queue: list):
152
+ """Run async pipeline in a thread-safe way."""
153
+ def progress_cb(msg: str):
154
+ log_queue.append(msg)
155
+
156
+ loop = asyncio.new_event_loop()
157
+ asyncio.set_event_loop(loop)
158
+ try:
159
+ result = loop.run_until_complete(
160
+ perform_hybrid_evaluation(jd, candidates, progress_cb=progress_cb)
161
+ )
162
+ return result, None
163
+ except Exception as e:
164
+ return None, str(e)
165
+ finally:
166
+ loop.close()
167
+
168
+
169
+ # ─────────────────────────────────────────────────────────────────────────────
170
+ # Gradio App
171
+ # ─────────────────────────────────────────────────────────────────────────────
172
+
173
+ CSS = """
174
+ /* ── Root & Typography ── */
175
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@400;700;800&display=swap');
176
+
177
+ :root {
178
+ --bg: #0a0a0f;
179
+ --surface: #12121a;
180
+ --border: #1e1e2e;
181
+ --accent: #6ee7b7;
182
+ --accent2: #818cf8;
183
+ --warn: #fbbf24;
184
+ --danger: #f87171;
185
+ --text: #e2e8f0;
186
+ --muted: #64748b;
187
+ --radius: 8px;
188
+ }
189
+
190
+ body, .gradio-container {
191
+ background: var(--bg) !important;
192
+ font-family: 'Syne', sans-serif !important;
193
+ color: var(--text) !important;
194
+ }
195
+
196
+ /* Header */
197
+ .app-header {
198
+ background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
199
+ border-bottom: 1px solid var(--accent2);
200
+ padding: 24px 32px;
201
+ margin-bottom: 0;
202
+ }
203
+ .app-header h1 {
204
+ font-family: 'Syne', sans-serif;
205
+ font-weight: 800;
206
+ font-size: 2rem;
207
+ color: var(--accent);
208
+ margin: 0;
209
+ letter-spacing: -0.5px;
210
+ }
211
+ .app-header p {
212
+ color: var(--muted);
213
+ font-family: 'IBM Plex Mono', monospace;
214
+ font-size: 0.78rem;
215
+ margin: 4px 0 0;
216
+ }
217
+
218
+ /* Panels */
219
+ .panel {
220
+ background: var(--surface);
221
+ border: 1px solid var(--border);
222
+ border-radius: var(--radius);
223
+ padding: 20px;
224
+ }
225
+
226
+ /* Labels */
227
+ label span {
228
+ font-family: 'IBM Plex Mono', monospace !important;
229
+ font-size: 0.72rem !important;
230
+ color: var(--accent2) !important;
231
+ text-transform: uppercase;
232
+ letter-spacing: 0.08em;
233
+ }
234
+
235
+ /* Textboxes */
236
+ textarea, input[type="text"] {
237
+ background: #0d0d16 !important;
238
+ border: 1px solid var(--border) !important;
239
+ border-radius: var(--radius) !important;
240
+ color: var(--text) !important;
241
+ font-family: 'IBM Plex Mono', monospace !important;
242
+ font-size: 0.82rem !important;
243
+ }
244
+ textarea:focus, input:focus {
245
+ border-color: var(--accent2) !important;
246
+ box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.15) !important;
247
+ }
248
+
249
+ /* Buttons */
250
+ button.primary {
251
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
252
+ color: white !important;
253
+ border: none !important;
254
+ border-radius: var(--radius) !important;
255
+ font-family: 'Syne', sans-serif !important;
256
+ font-weight: 700 !important;
257
+ font-size: 0.95rem !important;
258
+ padding: 12px 28px !important;
259
+ transition: all 0.2s ease !important;
260
+ letter-spacing: 0.02em;
261
+ }
262
+ button.primary:hover {
263
+ transform: translateY(-1px) !important;
264
+ box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4) !important;
265
+ }
266
+ button.secondary {
267
+ background: transparent !important;
268
+ border: 1px solid var(--border) !important;
269
+ color: var(--muted) !important;
270
+ border-radius: var(--radius) !important;
271
+ font-family: 'IBM Plex Mono', monospace !important;
272
+ font-size: 0.8rem !important;
273
+ }
274
+ button.secondary:hover {
275
+ border-color: var(--accent2) !important;
276
+ color: var(--accent2) !important;
277
+ }
278
+
279
+ /* Log box */
280
+ .log-box textarea {
281
+ font-family: 'IBM Plex Mono', monospace !important;
282
+ font-size: 0.75rem !important;
283
+ color: var(--accent) !important;
284
+ background: #050508 !important;
285
+ border-color: #1a1a2e !important;
286
+ line-height: 1.6;
287
+ }
288
+
289
+ /* Dataframe */
290
+ .dataframe th {
291
+ background: #1a1a2e !important;
292
+ color: var(--accent2) !important;
293
+ font-family: 'IBM Plex Mono', monospace !important;
294
+ font-size: 0.72rem !important;
295
+ text-transform: uppercase;
296
+ letter-spacing: 0.06em;
297
+ }
298
+ .dataframe td {
299
+ font-family: 'IBM Plex Mono', monospace !important;
300
+ font-size: 0.8rem !important;
301
+ color: var(--text) !important;
302
+ border-color: var(--border) !important;
303
+ }
304
+
305
+ /* Status badge */
306
+ .status-badge {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ gap: 6px;
310
+ padding: 4px 12px;
311
+ border-radius: 20px;
312
+ font-family: 'IBM Plex Mono', monospace;
313
+ font-size: 0.75rem;
314
+ font-weight: 600;
315
+ }
316
+
317
+ /* Tabs */
318
+ .tab-nav button {
319
+ font-family: 'IBM Plex Mono', monospace !important;
320
+ font-size: 0.8rem !important;
321
+ color: var(--muted) !important;
322
+ border-bottom: 2px solid transparent !important;
323
+ background: transparent !important;
324
+ }
325
+ .tab-nav button.selected {
326
+ color: var(--accent) !important;
327
+ border-bottom-color: var(--accent) !important;
328
+ }
329
+
330
+ /* Markdown output */
331
+ .markdown-body {
332
+ font-family: 'Syne', sans-serif;
333
+ color: var(--text);
334
+ line-height: 1.7;
335
+ }
336
+ .markdown-body h3 {
337
+ color: var(--accent2);
338
+ font-size: 1.05rem;
339
+ margin-top: 24px;
340
+ }
341
+ .markdown-body strong {
342
+ color: var(--accent);
343
+ }
344
+ .markdown-body hr {
345
+ border-color: var(--border);
346
+ }
347
+
348
+ /* Pipeline steps */
349
+ .pipeline-step {
350
+ display: inline-block;
351
+ padding: 3px 10px;
352
+ margin: 2px;
353
+ border-radius: 4px;
354
+ font-family: 'IBM Plex Mono', monospace;
355
+ font-size: 0.7rem;
356
+ background: #1a1a2e;
357
+ color: var(--accent2);
358
+ border: 1px solid #2d2d5e;
359
+ }
360
+
361
+ /* Accent divider */
362
+ .divider {
363
+ height: 2px;
364
+ background: linear-gradient(90deg, var(--accent2), transparent);
365
+ margin: 16px 0;
366
+ border: none;
367
+ }
368
+ """
369
+
370
+
371
+ def create_app():
372
+ with gr.Blocks(
373
+ css=CSS,
374
+ title="AI Recruitment Agent",
375
+ theme=gr.themes.Base(
376
+ primary_hue="violet",
377
+ neutral_hue="slate",
378
+ ),
379
+ ) as app:
380
+
381
+ # ── State ──────────────────────────────────────────────
382
+ candidates_state = gr.State([])
383
+ response_state = gr.State(None)
384
+
385
+ # ── Header ─────────────────────────────────────────────
386
+ gr.HTML("""
387
+ <div class="app-header">
388
+ <h1>⚡ AI Recruitment Agent</h1>
389
+ <p>5-stage hybrid pipeline · Groq LLM · Pinecone embeddings · Deterministic reranking</p>
390
+ </div>
391
+ <div style="display:flex; gap:8px; padding:12px 32px; background:#0c0c14; border-bottom:1px solid #1e1e2e;">
392
+ <span class="pipeline-step">① Normalize</span>
393
+ <span style="color:#64748b;align-self:center">→</span>
394
+ <span class="pipeline-step">② Embed</span>
395
+ <span style="color:#64748b;align-self:center">→</span>
396
+ <span class="pipeline-step">③ Rerank</span>
397
+ <span style="color:#64748b;align-self:center">→</span>
398
+ <span class="pipeline-step">④ Deep Review</span>
399
+ <span style="color:#64748b;align-self:center">→</span>
400
+ <span class="pipeline-step">⑤ Shortlist</span>
401
+ </div>
402
+ """)
403
+
404
+ # ── Main Layout ────────────────────────────────────────
405
+ with gr.Row(equal_height=False):
406
+
407
+ # Left column — inputs
408
+ with gr.Column(scale=4, min_width=360):
409
+ gr.HTML('<div style="height:16px"></div>')
410
+
411
+ # JD input
412
+ jd_input = gr.Textbox(
413
+ label="📋 Job Description",
414
+ placeholder="Paste the full job description here...",
415
+ lines=14,
416
+ value=SAMPLE_JD,
417
+ elem_classes=["panel"],
418
+ )
419
+
420
+ gr.HTML('<div style="height:12px"></div>')
421
+
422
+ # CSV upload
423
+ csv_upload = gr.File(
424
+ label="📂 Upload Candidates CSV",
425
+ file_types=[".csv"],
426
+ elem_classes=["panel"],
427
+ )
428
+
429
+ # Candidate count badge
430
+ candidate_count = gr.HTML(
431
+ '<div style="color:#64748b; font-family:\'IBM Plex Mono\',monospace; font-size:0.75rem; padding:6px 0;">No candidates loaded</div>'
432
+ )
433
+
434
+ gr.HTML('<div style="height:12px"></div>')
435
+
436
+ # Preview table
437
+ preview_table = gr.Dataframe(
438
+ label="👥 Candidate Preview",
439
+ headers=["Name", "Email", "Skills Preview"],
440
+ datatype=["str", "str", "str"],
441
+ visible=False,
442
+ wrap=True,
443
+ elem_classes=["panel"],
444
+ )
445
+
446
+ gr.HTML('<div style="height:16px"></div>')
447
+
448
+ # Action buttons
449
+ with gr.Row():
450
+ run_btn = gr.Button(
451
+ "🚀 Run Evaluation",
452
+ variant="primary",
453
+ scale=3,
454
+ )
455
+ clear_btn = gr.Button(
456
+ "↺ Reset",
457
+ variant="secondary",
458
+ scale=1,
459
+ )
460
+
461
+ # Right column — outputs
462
+ with gr.Column(scale=6, min_width=500):
463
+ gr.HTML('<div style="height:16px"></div>')
464
+
465
+ with gr.Tabs(elem_classes=["tab-nav"]):
466
+
467
+ # Tab 1 — Live Log
468
+ with gr.Tab("📡 Live Pipeline Log"):
469
+ log_output = gr.Textbox(
470
+ label="",
471
+ lines=18,
472
+ interactive=False,
473
+ placeholder="Pipeline logs will appear here...",
474
+ elem_classes=["log-box"],
475
+ )
476
+
477
+ # Tab 2 — Results Table
478
+ with gr.Tab("🏆 Shortlist"):
479
+ status_html = gr.HTML(
480
+ '<div style="color:#64748b;font-family:\'IBM Plex Mono\',monospace;font-size:0.8rem;padding:8px 0;">Run evaluation to see results.</div>'
481
+ )
482
+ results_table = gr.Dataframe(
483
+ label="Final Shortlist",
484
+ wrap=True,
485
+ elem_classes=["panel"],
486
+ )
487
+
488
+ # Tab 3 — Deep Reviews
489
+ with gr.Tab("🔍 Deep Reviews"):
490
+ detail_output = gr.Markdown(
491
+ value="_Run evaluation to see candidate deep reviews._",
492
+ )
493
+
494
+ # Tab 4 — Raw JSON
495
+ with gr.Tab("{ } Raw JSON"):
496
+ raw_json_output = gr.Code(
497
+ language="json",
498
+ label="Full API Response",
499
+ lines=30,
500
+ )
501
+
502
+ # ── Event Handlers ──────────────────────────────────────
503
+
504
+ def on_csv_upload(file):
505
+ if file is None:
506
+ return (
507
+ [],
508
+ '<div style="color:#64748b;font-family:\'IBM Plex Mono\',monospace;font-size:0.75rem;padding:6px 0;">No candidates loaded</div>',
509
+ gr.update(visible=False),
510
+ pd.DataFrame(),
511
+ )
512
+
513
+ candidates, df, err = parse_csv_to_candidates(file.name)
514
+ if err:
515
+ return (
516
+ [],
517
+ f'<div style="color:#f87171;font-family:\'IBM Plex Mono\',monospace;font-size:0.75rem;padding:6px 0;">⚠ {err}</div>',
518
+ gr.update(visible=False),
519
+ pd.DataFrame(),
520
+ )
521
+
522
+ count = len(candidates)
523
+ badge_color = "#22c55e" if count > 0 else "#f87171"
524
+ badge = f'<div style="color:{badge_color};font-family:\'IBM Plex Mono\',monospace;font-size:0.75rem;padding:6px 0;">✓ {count} candidates loaded from CSV</div>'
525
+
526
+ # Build preview
527
+ preview_rows = []
528
+ for c in candidates[:10]:
529
+ skills_preview = (c.skills or "")[:80] + ("..." if len(c.skills or "") > 80 else "")
530
+ preview_rows.append([c.name, c.email or "—", skills_preview])
531
+ preview_df = pd.DataFrame(preview_rows, columns=["Name", "Email", "Skills Preview"])
532
+
533
+ return candidates, badge, gr.update(visible=True), preview_df
534
+
535
+ csv_upload.change(
536
+ fn=on_csv_upload,
537
+ inputs=[csv_upload],
538
+ outputs=[candidates_state, candidate_count, preview_table, preview_table],
539
+ )
540
+
541
+ def on_run(jd: str, candidates: list):
542
+ if not jd.strip():
543
+ yield (
544
+ "⚠ Please enter a Job Description.",
545
+ gr.update(),
546
+ "_No results yet._",
547
+ "{}",
548
+ '<div style="color:#f87171;font-size:0.8rem;">Job description required.</div>',
549
+ None,
550
+ )
551
+ return
552
+
553
+ if not candidates:
554
+ yield (
555
+ "⚠ Please upload a CSV file with candidates first.",
556
+ gr.update(),
557
+ "_No results yet._",
558
+ "{}",
559
+ '<div style="color:#f87171;font-size:0.8rem;">No candidates loaded.</div>',
560
+ None,
561
+ )
562
+ return
563
+
564
+ log_queue = []
565
+ result_holder = [None]
566
+ error_holder = [None]
567
+
568
+ # Run in thread
569
+ def run():
570
+ res, err = run_evaluation_sync(jd, candidates, log_queue)
571
+ result_holder[0] = res
572
+ error_holder[0] = err
573
+
574
+ thread = threading.Thread(target=run)
575
+ thread.start()
576
+
577
+ # Stream logs while running
578
+ import time
579
+ last_log_len = 0
580
+ while thread.is_alive():
581
+ time.sleep(0.5)
582
+ if len(log_queue) > last_log_len:
583
+ last_log_len = len(log_queue)
584
+ log_text = "\n".join(log_queue)
585
+ yield (
586
+ log_text,
587
+ gr.update(),
588
+ "_Processing..._",
589
+ "{}",
590
+ '<div style="color:#818cf8;font-size:0.8rem;font-family:\'IBM Plex Mono\',monospace;">⏳ Evaluating candidates...</div>',
591
+ None,
592
+ )
593
+
594
+ thread.join()
595
+
596
+ final_logs = "\n".join(log_queue)
597
+
598
+ if error_holder[0]:
599
+ yield (
600
+ final_logs + f"\n\n❌ ERROR: {error_holder[0]}",
601
+ gr.update(),
602
+ "_Evaluation failed._",
603
+ "{}",
604
+ f'<div style="color:#f87171;font-size:0.8rem;">❌ {error_holder[0]}</div>',
605
+ None,
606
+ )
607
+ return
608
+
609
+ response: EvaluationResponse = result_holder[0]
610
+
611
+ # Build outputs
612
+ shortlist_df = build_shortlist_table(response)
613
+ detail_md = build_detail_md(response, shortlist_df)
614
+ raw_json = json.dumps(response.model_dump(), indent=2)
615
+
616
+ n = len(response.shortlist)
617
+ top = response.shortlist[0] if response.shortlist else None
618
+ top_name = top.name if top else "—"
619
+ top_decision = top.decision if top else "—"
620
+ emoji = VERDICT_EMOJI.get((top_decision or "").lower(), "⚪")
621
+
622
+ status = f'''
623
+ <div style="display:flex;gap:16px;align-items:center;padding:8px 0;">
624
+ <div style="color:#22c55e;font-family:'IBM Plex Mono',monospace;font-size:0.8rem;">
625
+ ✓ Evaluation complete · {n} candidates shortlisted
626
+ </div>
627
+ <div style="color:#64748b;font-family:'IBM Plex Mono',monospace;font-size:0.8rem;">
628
+ Top pick: <span style="color:#e2e8f0">{top_name}</span> {emoji}
629
+ </div>
630
+ </div>
631
+ '''
632
+
633
+ yield (
634
+ final_logs + "\n\n✅ Evaluation complete.",
635
+ shortlist_df,
636
+ detail_md,
637
+ raw_json,
638
+ status,
639
+ response,
640
+ )
641
+
642
+ run_btn.click(
643
+ fn=on_run,
644
+ inputs=[jd_input, candidates_state],
645
+ outputs=[
646
+ log_output,
647
+ results_table,
648
+ detail_output,
649
+ raw_json_output,
650
+ status_html,
651
+ response_state,
652
+ ],
653
+ )
654
+
655
+ def on_clear():
656
+ return (
657
+ [],
658
+ SAMPLE_JD,
659
+ None,
660
+ "",
661
+ pd.DataFrame(),
662
+ "_No results yet._",
663
+ "{}",
664
+ '<div style="color:#64748b;font-family:\'IBM Plex Mono\',monospace;font-size:0.75rem;padding:6px 0;">No candidates loaded</div>',
665
+ gr.update(visible=False),
666
+ pd.DataFrame(),
667
+ '<div style="color:#64748b;font-size:0.8rem;font-family:\'IBM Plex Mono\',monospace;">Run evaluation to see results.</div>',
668
+ )
669
+
670
+ clear_btn.click(
671
+ fn=on_clear,
672
+ outputs=[
673
+ candidates_state,
674
+ jd_input,
675
+ csv_upload,
676
+ log_output,
677
+ results_table,
678
+ detail_output,
679
+ raw_json_output,
680
+ candidate_count,
681
+ preview_table,
682
+ preview_table,
683
+ status_html,
684
+ ],
685
+ )
686
+
687
+ # ── Footer ─────────────────────────────────────────────
688
+ gr.HTML("""
689
+ <div style="text-align:center;padding:20px;color:#334155;font-family:'IBM Plex Mono',monospace;font-size:0.7rem;border-top:1px solid #1e1e2e;margin-top:24px;">
690
+ AI Recruitment Agent · Groq + Pinecone + SentenceTransformers · Gradio 4.16.0
691
+ </div>
692
+ """)
693
+
694
+ return app
695
+
696
+
697
+ if __name__ == "__main__":
698
+ share = os.getenv("GRADIO_SHARE", "false").lower() == "true"
699
+ port = int(os.getenv("GRADIO_PORT", "7860"))
700
+
701
+ print(f"\n{'='*50}")
702
+ print(" AI Recruitment Agent")
703
+ print(f" Starting on http://0.0.0.0:{port}")
704
+ print(f" Public share: {share}")
705
+ print(f"{'='*50}\n")
706
+
707
+ app = create_app()
708
+ app.queue().launch(
709
+ server_name="0.0.0.0",
710
+ server_port=port,
711
+ share=share,
712
+ show_error=True,
713
+ )
app/__init__.py ADDED
File without changes
app/models/__init__.py ADDED
File without changes
app/models/schemas.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional, Dict, Any
3
+
4
+
5
+ class Candidate(BaseModel):
6
+ id: str
7
+ name: str
8
+ email: Optional[str] = None
9
+ skills: Optional[str] = None
10
+ experience: Optional[str] = None
11
+ projects: Optional[str] = None
12
+ education: Optional[str] = None
13
+ resume_text: Optional[str] = None
14
+ data: Optional[Dict[str, Any]] = None
15
+
16
+
17
+ class NormalizedCandidate(BaseModel):
18
+ candidate_id: str
19
+ name: str
20
+ normalized_title: str
21
+ experience_years: float
22
+ primary_skills: List[str]
23
+ secondary_skills: List[str]
24
+ backend_score: float
25
+ frontend_score: float
26
+ cloud_score: float
27
+ database_score: float
28
+ notice_period_days: int
29
+ location: str
30
+ employment_status: str
31
+ salary_expectation: str
32
+ flags: List[str]
33
+
34
+
35
+ class RerankResult(BaseModel):
36
+ candidate_id: str
37
+ scores: Dict[str, float]
38
+ final_score: float
39
+ decision: str
40
+
41
+
42
+ class DeepReview(BaseModel):
43
+ candidate_id: str
44
+ verdict: str
45
+ why: str
46
+ strengths: List[str]
47
+ risks: List[str]
48
+ hidden_signal: str
49
+ confidence: float
50
+
51
+
52
+ class FinalRank(BaseModel):
53
+ rank: int
54
+ candidate_id: str
55
+ name: str
56
+ decision: str
57
+ reason: str
58
+
59
+
60
+ class FinalShortlist(BaseModel):
61
+ final_ranking: List[FinalRank]
62
+
63
+
64
+ class EvaluationRequest(BaseModel):
65
+ jd: str
66
+ candidates: List[Candidate]
67
+
68
+
69
+ class EvaluationResponse(BaseModel):
70
+ shortlist: List[FinalRank]
71
+ details: Dict[str, Any]
app/prompts/__init__.py ADDED
File without changes
app/prompts/templates.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ STAGE1_NORMALIZATION_PROMPT = """
2
+ You are a candidate normalization system. Your ONLY job is to extract and clean structured fields from raw candidate data.
3
+
4
+ JOB DESCRIPTION:
5
+ {jd}
6
+
7
+ CANDIDATE RAW DATA (JSON):
8
+ {candidate_raw}
9
+
10
+ RULES:
11
+ - Output ONLY valid JSON. No markdown, no explanation, no preamble.
12
+ - Do not hallucinate any information not present in the data.
13
+ - Standardize all values (e.g., notice period → integer days).
14
+ - Score backend/frontend/cloud/database from 0-10 based on skills in the candidate data vs JD requirements.
15
+ - flags: list any concerns like "No cloud experience", "Very junior", "Mismatch in title", etc.
16
+
17
+ OUTPUT JSON (exactly this schema):
18
+ {{
19
+ "candidate_id": "{candidate_id}",
20
+ "name": "",
21
+ "normalized_title": "",
22
+ "experience_years": 0.0,
23
+ "primary_skills": [],
24
+ "secondary_skills": [],
25
+ "backend_score": 0,
26
+ "frontend_score": 0,
27
+ "cloud_score": 0,
28
+ "database_score": 0,
29
+ "notice_period_days": 0,
30
+ "location": "",
31
+ "employment_status": "",
32
+ "salary_expectation": "",
33
+ "flags": []
34
+ }}
35
+ """
36
+
37
+ STAGE3_RERANK_PROMPT = """
38
+ You are a deterministic candidate scoring engine.
39
+
40
+ JOB DESCRIPTION:
41
+ {jd}
42
+
43
+ NORMALIZED CANDIDATE DATA:
44
+ {normalized_candidate}
45
+
46
+ TASK:
47
+ Score this candidate using the following weighted criteria:
48
+
49
+ WEIGHTS:
50
+ - Skill Match (35%): How well do their primary/secondary skills match JD required skills?
51
+ - Experience Match (25%): Does their experience level match the JD minimum?
52
+ - Role Relevance (20%): Is their normalized title and domain relevant to the JD?
53
+ - Cloud/Infra Fit (10%): Do they have cloud, DevOps, or infra skills mentioned in JD?
54
+ - Notice Period Fit (10%): Is their notice period suitable? (< 30 days = 10, < 60 = 7, < 90 = 5, > 90 = 2)
55
+
56
+ RULES:
57
+ - Score each dimension 0–100.
58
+ - Compute final_score as weighted average.
59
+ - decision: "pass" if final_score >= 60, else "reject".
60
+ - Output ONLY valid JSON. No explanation.
61
+
62
+ OUTPUT JSON:
63
+ {{
64
+ "candidate_id": "",
65
+ "scores": {{
66
+ "skill_match": 0,
67
+ "experience_match": 0,
68
+ "role_relevance": 0,
69
+ "infra_fit": 0,
70
+ "notice_fit": 0
71
+ }},
72
+ "final_score": 0,
73
+ "decision": "pass"
74
+ }}
75
+ """
76
+
77
+ STAGE4_DEEP_REVIEW_PROMPT = """
78
+ You are a senior hiring evaluator at a top tech company. You receive only the strongest pre-screened candidates.
79
+
80
+ JOB DESCRIPTION:
81
+ {jd}
82
+
83
+ CANDIDATE FULL DATA:
84
+ {candidate_data}
85
+
86
+ RERANK SCORE: {score}/100
87
+
88
+ TASK:
89
+ Perform a deep, nuanced evaluation. Identify hidden strengths, practical fit signals, risks, and make a clear hiring recommendation.
90
+
91
+ RULES:
92
+ - Use only the data provided. No hallucinations.
93
+ - Be decisive. Avoid vague language.
94
+ - hidden_signal: any non-obvious positive or negative signal (company pedigree, project quality, progression speed, etc.)
95
+ - confidence: 0.0 to 1.0
96
+
97
+ OUTPUT JSON:
98
+ {{
99
+ "verdict": "strong hire | hire | consider | reject",
100
+ "why": "one clear sentence explaining the verdict",
101
+ "strengths": ["strength 1", "strength 2"],
102
+ "risks": ["risk 1", "risk 2"],
103
+ "hidden_signal": "any non-obvious insight",
104
+ "confidence": 0.0
105
+ }}
106
+ """
107
+
108
+ STAGE5_FINAL_SELECTION_PROMPT = """
109
+ You are the final hiring decision officer. You have all LLM deep reviews for the top 5 candidates.
110
+
111
+ ALL TOP CANDIDATE REVIEWS:
112
+ {all_top_5_results}
113
+
114
+ TASK:
115
+ Synthesize all reviews and produce the final ranked shortlist. Consider:
116
+ - Verdict strength (strong hire > hire > consider > reject)
117
+ - Confidence scores
118
+ - Risk levels
119
+ - Overall fit signals
120
+
121
+ OUTPUT ONLY valid JSON:
122
+ {{
123
+ "final_ranking": [
124
+ {{
125
+ "rank": 1,
126
+ "candidate_id": "",
127
+ "name": "",
128
+ "decision": "strong hire | hire | consider | reject",
129
+ "reason": "one concise sentence"
130
+ }}
131
+ ]
132
+ }}
133
+ """
app/services/__init__.py ADDED
File without changes
app/services/evaluation_service.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import re
4
+ import logging
5
+ from typing import List, Dict, Any, Callable, Optional
6
+
7
+ from app.utils.groq_client import get_groq_completion
8
+ from app.models.schemas import (
9
+ Candidate, NormalizedCandidate, RerankResult,
10
+ DeepReview, FinalShortlist, FinalRank, EvaluationResponse,
11
+ )
12
+ from app.services.matching_service import match_service
13
+ from app.prompts.templates import (
14
+ STAGE1_NORMALIZATION_PROMPT,
15
+ STAGE3_RERANK_PROMPT,
16
+ STAGE4_DEEP_REVIEW_PROMPT,
17
+ STAGE5_FINAL_SELECTION_PROMPT,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Concurrency throttle — max 3 parallel Groq calls
23
+ sem = asyncio.Semaphore(3)
24
+
25
+
26
+ async def _llm(messages: list) -> str:
27
+ async with sem:
28
+ return await get_groq_completion(messages)
29
+
30
+
31
+ def _parse_json(raw: str) -> dict:
32
+ """Extract first JSON object from LLM response."""
33
+ match = re.search(r'\{.*\}', raw, re.DOTALL)
34
+ if match:
35
+ return json.loads(match.group())
36
+ return json.loads(raw)
37
+
38
+
39
+ # ────────────────────────────────────────────────
40
+ # Stage 1 — Normalize
41
+ # ────────────────────────────────────────────────
42
+ async def normalize_candidate(jd: str, candidate: Candidate) -> NormalizedCandidate:
43
+ candidate_raw = candidate.model_dump_json()
44
+ prompt = STAGE1_NORMALIZATION_PROMPT.format(
45
+ jd=jd,
46
+ candidate_raw=candidate_raw,
47
+ candidate_id=candidate.id,
48
+ )
49
+ resp = await _llm([
50
+ {"role": "system", "content": "You are a professional data normalizer. Output JSON ONLY. No markdown."},
51
+ {"role": "user", "content": prompt},
52
+ ])
53
+ try:
54
+ data = _parse_json(resp)
55
+ data["candidate_id"] = candidate.id # Ensure ID is always correct
56
+ return NormalizedCandidate(**data)
57
+ except Exception as e:
58
+ logger.warning(f"[Stage1] Failed to normalize {candidate.name}: {e}")
59
+ return NormalizedCandidate(
60
+ candidate_id=candidate.id,
61
+ name=candidate.name,
62
+ normalized_title="Unknown",
63
+ experience_years=0,
64
+ primary_skills=[],
65
+ secondary_skills=[],
66
+ backend_score=0,
67
+ frontend_score=0,
68
+ cloud_score=0,
69
+ database_score=0,
70
+ notice_period_days=90,
71
+ location="Unknown",
72
+ employment_status="Unknown",
73
+ salary_expectation="Unknown",
74
+ flags=["Normalization Error"],
75
+ )
76
+
77
+
78
+ # ────────────────────────────────────────────────
79
+ # Stage 3 — Rerank
80
+ # ────────────────────────────────────────────────
81
+ async def rerank_candidate(jd: str, normalized: NormalizedCandidate) -> RerankResult:
82
+ resp = await _llm([
83
+ {"role": "system", "content": "You are a recruitment scoring engine. Output JSON ONLY. No markdown."},
84
+ {"role": "user", "content": STAGE3_RERANK_PROMPT.format(
85
+ jd=jd,
86
+ normalized_candidate=normalized.model_dump_json(),
87
+ )},
88
+ ])
89
+ try:
90
+ data = _parse_json(resp)
91
+ data["candidate_id"] = normalized.candidate_id
92
+ return RerankResult(**data)
93
+ except Exception as e:
94
+ logger.warning(f"[Stage3] Rerank failed for {normalized.candidate_id}: {e}")
95
+ return RerankResult(
96
+ candidate_id=normalized.candidate_id,
97
+ scores={},
98
+ final_score=0,
99
+ decision="reject",
100
+ )
101
+
102
+
103
+ # ────────────────────────────────────────────────
104
+ # Stage 4 — Deep Review
105
+ # ────────────────────────────────────────────────
106
+ async def review_candidate(
107
+ jd: str, candidate: Candidate, score: float
108
+ ) -> DeepReview:
109
+ resp = await _llm([
110
+ {"role": "system", "content": "You are a senior hiring evaluator. Output JSON ONLY. No markdown."},
111
+ {"role": "user", "content": STAGE4_DEEP_REVIEW_PROMPT.format(
112
+ jd=jd,
113
+ candidate_data=candidate.model_dump_json(),
114
+ score=round(score, 1),
115
+ )},
116
+ ])
117
+ try:
118
+ data = _parse_json(resp)
119
+ data["candidate_id"] = candidate.id
120
+ return DeepReview(**data)
121
+ except Exception as e:
122
+ logger.warning(f"[Stage4] Deep review failed for {candidate.id}: {e}")
123
+ return DeepReview(
124
+ candidate_id=candidate.id,
125
+ verdict="reject",
126
+ why="Evaluation error — could not parse LLM response.",
127
+ strengths=[],
128
+ risks=["Evaluation error"],
129
+ hidden_signal="",
130
+ confidence=0.0,
131
+ )
132
+
133
+
134
+ # ────────────────────────────────────────────────
135
+ # Main Pipeline
136
+ # ────────────────────────────────────────────────
137
+ async def perform_hybrid_evaluation(
138
+ jd: str,
139
+ candidates: List[Candidate],
140
+ progress_cb: Optional[Callable[[str], None]] = None,
141
+ ) -> EvaluationResponse:
142
+ """
143
+ Full 5-stage hybrid evaluation pipeline.
144
+ progress_cb: optional callable for streaming progress logs to UI.
145
+ """
146
+
147
+ def log(msg: str):
148
+ logger.info(msg)
149
+ if progress_cb:
150
+ progress_cb(msg)
151
+
152
+ candidate_map = {c.id: c for c in candidates}
153
+
154
+ # ── Stage 1: Normalize all candidates ──────────────────────
155
+ log(f"[Stage 1] Normalizing {len(candidates)} candidates...")
156
+ norm_tasks = [normalize_candidate(jd, c) for c in candidates]
157
+ normalized_list: List[NormalizedCandidate] = await asyncio.gather(*norm_tasks)
158
+ normalized_map = {n.candidate_id: n for n in normalized_list}
159
+ log(f"[Stage 1] ✓ Normalization complete.")
160
+
161
+ # ── Stage 2: Embedding matching → Top 20 ───────────────────
162
+ log(f"[Stage 2] Running embedding match against Pinecone...")
163
+ try:
164
+ top_20 = await match_service.get_top_candidates(jd, candidates)
165
+ except Exception as e:
166
+ log(f"[Stage 2] ⚠ Pinecone unavailable ({e}). Falling back to all candidates.")
167
+ top_20 = candidates[:20]
168
+
169
+ # Clamp to available
170
+ top_20 = top_20[:20]
171
+ log(f"[Stage 2] ✓ Retrieved {len(top_20)} candidates.")
172
+
173
+ # ── Stage 3: Deterministic rerank → Top 10 ─────────────────
174
+ log(f"[Stage 3] Reranking {len(top_20)} candidates...")
175
+ rerank_tasks = [
176
+ rerank_candidate(jd, normalized_map[c.id])
177
+ for c in top_20
178
+ if c.id in normalized_map
179
+ ]
180
+ rerank_results: List[RerankResult] = await asyncio.gather(*rerank_tasks)
181
+ rerank_results.sort(key=lambda x: x.final_score, reverse=True)
182
+ top_10 = rerank_results[:10]
183
+ log(f"[Stage 3] ✓ Top 10 selected. Scores: {[round(r.final_score, 1) for r in top_10]}")
184
+
185
+ # ── Stage 4: LLM deep review → Top 5 ──────────────────────
186
+ top_5_results = top_10[:5]
187
+ log(f"[Stage 4] Deep reviewing top {len(top_5_results)} candidates...")
188
+ review_tasks = [
189
+ review_candidate(jd, candidate_map[r.candidate_id], r.final_score)
190
+ for r in top_5_results
191
+ if r.candidate_id in candidate_map
192
+ ]
193
+ reviews: List[DeepReview] = await asyncio.gather(*review_tasks)
194
+ review_map = {rev.candidate_id: rev for rev in reviews}
195
+ log(f"[Stage 4] ✓ Deep reviews complete.")
196
+
197
+ # ── Stage 5: Final synthesis ───────────────────────────────
198
+ log(f"[Stage 5] Synthesizing final shortlist...")
199
+ reviews_json = json.dumps([r.model_dump() for r in reviews])
200
+ final_resp = await _llm([
201
+ {"role": "system", "content": "You are the final hiring decision officer. Output JSON ONLY. No markdown."},
202
+ {"role": "user", "content": STAGE5_FINAL_SELECTION_PROMPT.format(
203
+ all_top_5_results=reviews_json
204
+ )},
205
+ ])
206
+
207
+ try:
208
+ final_data = _parse_json(final_resp)
209
+ shortlist = FinalShortlist(**final_data)
210
+ except Exception as e:
211
+ log(f"[Stage 5] ⚠ Synthesis parse failed ({e}). Using automatic ranking.")
212
+ shortlist = FinalShortlist(
213
+ final_ranking=[
214
+ FinalRank(
215
+ rank=i + 1,
216
+ candidate_id=r.candidate_id,
217
+ name=candidate_map.get(r.candidate_id, Candidate(id=r.candidate_id, name="Unknown")).name,
218
+ decision=review_map.get(r.candidate_id, DeepReview(
219
+ candidate_id=r.candidate_id, verdict="consider", why="", strengths=[],
220
+ risks=[], hidden_signal="", confidence=0
221
+ )).verdict,
222
+ reason="Auto-ranked by rerank score.",
223
+ )
224
+ for i, r in enumerate(top_5_results)
225
+ ]
226
+ )
227
+
228
+ log(f"[Stage 5] ✓ Pipeline complete. {len(shortlist.final_ranking)} candidates shortlisted.")
229
+
230
+ return EvaluationResponse(
231
+ shortlist=shortlist.final_ranking,
232
+ details={rev.candidate_id: rev.model_dump() for rev in reviews},
233
+ )
app/services/matching_service.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ import logging
4
+ from typing import List, Tuple
5
+
6
+ from app.models.schemas import Candidate
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class MatchService:
12
+ """
13
+ Stage 2: Embedding-based semantic matching using Pinecone + SentenceTransformers.
14
+ Stores candidate embeddings, queries with JD embedding, returns top-K candidates.
15
+ """
16
+
17
+ def __init__(self):
18
+ self._model = None
19
+ self._index = None
20
+ self._initialized = False
21
+
22
+ def _lazy_init(self):
23
+ """Defer heavy imports until first use to keep startup fast."""
24
+ if self._initialized:
25
+ return
26
+
27
+ try:
28
+ from pinecone import Pinecone
29
+ from sentence_transformers import SentenceTransformer
30
+
31
+ api_key = os.getenv("PINECONE_API_KEY", "")
32
+ index_name = os.getenv("PINECONE_INDEX", "recruitment-index")
33
+ model_name = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
34
+
35
+ if not api_key:
36
+ raise ValueError("PINECONE_API_KEY not set in environment.")
37
+
38
+ logger.info(f"[MatchService] Connecting to Pinecone index: {index_name}")
39
+ pc = Pinecone(api_key=api_key)
40
+ self._index = pc.Index(index_name)
41
+
42
+ logger.info(f"[MatchService] Loading embedding model: {model_name}")
43
+ self._model = SentenceTransformer(model_name)
44
+
45
+ self._initialized = True
46
+ logger.info("[MatchService] Ready.")
47
+
48
+ except Exception as e:
49
+ logger.error(f"[MatchService] Initialization failed: {e}")
50
+ raise
51
+
52
+ def get_embedding(self, text: str) -> List[float]:
53
+ self._lazy_init()
54
+ return self._model.encode(text, normalize_embeddings=True).tolist()
55
+
56
+ def _build_search_text(self, c: Candidate) -> str:
57
+ parts = [
58
+ c.name or "",
59
+ c.skills or "",
60
+ c.experience or "",
61
+ c.projects or "",
62
+ c.education or "",
63
+ c.resume_text or "",
64
+ ]
65
+ return " ".join(p for p in parts if p.strip())
66
+
67
+ async def get_top_candidates(
68
+ self, jd: str, candidates: List[Candidate], top_k: int = None
69
+ ) -> List[Candidate]:
70
+ """
71
+ 1. Embed all candidates and upsert to Pinecone.
72
+ 2. Embed JD and query Pinecone.
73
+ 3. Return top_k candidates sorted by similarity.
74
+ """
75
+ if top_k is None:
76
+ top_k = int(os.getenv("STAGE2_TOP_K", "20"))
77
+
78
+ self._lazy_init()
79
+ candidate_map = {c.id: c for c in candidates}
80
+
81
+ # Build and embed vectors (run in thread to avoid blocking event loop)
82
+ loop = asyncio.get_event_loop()
83
+
84
+ def build_vectors():
85
+ vectors = []
86
+ for c in candidates:
87
+ text = self._build_search_text(c)
88
+ embedding = self.get_embedding(text)
89
+ vectors.append({
90
+ "id": c.id,
91
+ "values": embedding,
92
+ "metadata": {
93
+ "name": c.name,
94
+ "email": c.email or "",
95
+ },
96
+ })
97
+ return vectors
98
+
99
+ logger.info(f"[MatchService] Embedding {len(candidates)} candidates...")
100
+ vectors = await loop.run_in_executor(None, build_vectors)
101
+
102
+ # Upsert in batches of 100 (Pinecone limit)
103
+ batch_size = 100
104
+ for i in range(0, len(vectors), batch_size):
105
+ batch = vectors[i: i + batch_size]
106
+ self._index.upsert(vectors=batch)
107
+
108
+ # Embed JD and query
109
+ logger.info("[MatchService] Querying Pinecone with JD embedding...")
110
+ jd_embedding = await loop.run_in_executor(None, self.get_embedding, jd)
111
+
112
+ effective_k = min(top_k, len(candidates))
113
+ query_results = self._index.query(
114
+ vector=jd_embedding,
115
+ top_k=effective_k,
116
+ include_metadata=True,
117
+ )
118
+
119
+ top_candidates: List[Candidate] = []
120
+ for match in query_results.matches:
121
+ if match.id in candidate_map:
122
+ top_candidates.append(candidate_map[match.id])
123
+
124
+ logger.info(f"[MatchService] Retrieved {len(top_candidates)} top candidates.")
125
+ return top_candidates
126
+
127
+ async def cleanup_index(self, candidate_ids: List[str]):
128
+ """Optional: remove candidate vectors after evaluation to keep index clean."""
129
+ try:
130
+ self._index.delete(ids=candidate_ids)
131
+ logger.info(f"[MatchService] Cleaned up {len(candidate_ids)} vectors from index.")
132
+ except Exception as e:
133
+ logger.warning(f"[MatchService] Cleanup failed: {e}")
134
+
135
+
136
+ match_service = MatchService()
app/utils/__init__.py ADDED
File without changes
app/utils/groq_client.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from groq import AsyncGroq
4
+ from app.utils.key_manager import key_manager
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ async def get_groq_completion(messages: list, model: str = None) -> str:
10
+ """
11
+ Calls Groq API with automatic key rotation on failure.
12
+ Retries across all available keys before raising.
13
+ """
14
+ if model is None:
15
+ model = os.getenv("GROQ_MODEL", "llama3-70b-8192")
16
+
17
+ max_retries = max(key_manager.key_count(), 1)
18
+ last_error = None
19
+
20
+ for attempt in range(max_retries):
21
+ try:
22
+ api_key = key_manager.get_next_key()
23
+ client = AsyncGroq(api_key=api_key)
24
+ response = await client.chat.completions.create(
25
+ messages=messages,
26
+ model=model,
27
+ temperature=0.2, # Low temp for deterministic structured output
28
+ max_tokens=2048,
29
+ )
30
+ return response.choices[0].message.content
31
+
32
+ except Exception as e:
33
+ logger.warning(f"[Groq] Attempt {attempt + 1}/{max_retries} failed: {e}")
34
+ last_error = e
35
+ continue
36
+
37
+ raise Exception(f"[Groq] All API keys exhausted. Last error: {last_error}")
app/utils/key_manager.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ from typing import List
4
+
5
+
6
+ class KeyRotationManager:
7
+ def __init__(self):
8
+ keys_str = os.getenv("GROQ_API_KEYS", "")
9
+ if not keys_str:
10
+ keys_str = os.getenv("GROQ_API_KEY", "")
11
+
12
+ self.keys = [k.strip() for k in keys_str.split(",") if k.strip()]
13
+ print(f"[KeyManager] Initialized with {len(self.keys)} key(s).")
14
+ if not self.keys:
15
+ print("[KeyManager] WARNING: No GROQ_API_KEYS or GROQ_API_KEY found in environment!")
16
+
17
+ self.current_index = 0
18
+ self.lock = threading.Lock()
19
+
20
+ def get_next_key(self) -> str:
21
+ with self.lock:
22
+ if not self.keys:
23
+ raise ValueError("No GROQ API keys found. Set GROQ_API_KEYS or GROQ_API_KEY in your .env file.")
24
+ key = self.keys[self.current_index]
25
+ self.current_index = (self.current_index + 1) % len(self.keys)
26
+ return key
27
+
28
+ def key_count(self) -> int:
29
+ return len(self.keys)
30
+
31
+
32
+ key_manager = KeyRotationManager()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ groq==0.11.0
4
+ pinecone==5.0.1
5
+ sentence-transformers==3.1.1
6
+ pandas==2.2.3
7
+ pydantic==2.9.2
8
+ python-dotenv==1.0.1
9
+ gradio==4.44.0
10
+ httpx==0.27.2
11
+ python-multipart==0.0.12
12
+ aiofiles==24.1.0