ketannnn commited on
Commit
aeacc04
·
1 Parent(s): 63c562f

feat: add gap analysis, LLM explainer via Groq, and all API routes

Browse files
backend/src/matching/llm_explainer.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from groq import AsyncGroq
2
+ from ..config import get_settings
3
+
4
+
5
+ SYSTEM_PROMPT = """You are a senior technical recruiter with deep engineering knowledge.
6
+ You analyze candidate profiles against job descriptions and provide concise, honest assessments.
7
+ Always ground your analysis in the specific data provided — never hallucinate skills or experience.
8
+ Structure your response in exactly two parts:
9
+ 1. FIT ANALYSIS (2-3 sentences on why the candidate is or isn't a good match)
10
+ 2. KEY GAPS (1-2 sentences addressing specific missing requirements)"""
11
+
12
+
13
+ def _build_prompt(jd: dict, candidate: dict, gaps: list[dict]) -> str:
14
+ gaps_text = ""
15
+ if gaps:
16
+ gap_lines = []
17
+ for g in gaps:
18
+ gap_lines.append(f"- {g['type'].replace('_', ' ').title()}: {g['detail']}")
19
+ gaps_text = "Pre-computed gaps:\n" + "\n".join(gap_lines)
20
+
21
+ skills_text = ", ".join(
22
+ (candidate.get("programming_languages") or [])
23
+ + (candidate.get("backend_frameworks") or [])
24
+ + (candidate.get("frontend_technologies") or [])
25
+ )
26
+
27
+ return f"""JOB DESCRIPTION:
28
+ Title: {jd.get('title', 'Unknown')}
29
+ Required Skills: {', '.join(jd.get('required_skills') or [])}
30
+ Min Experience: {jd.get('min_yoe', 'Not specified')} years
31
+ Engineer Type: {jd.get('engineer_type', 'Not specified')}
32
+ Location: {jd.get('location', 'Not specified')}
33
+
34
+ CANDIDATE PROFILE:
35
+ Summary: {candidate.get('parsed_summary') or 'Not provided'}
36
+ Skills: {candidate.get('parsed_skills') or skills_text or 'Not provided'}
37
+ Experience: {candidate.get('years_of_experience', 'Unknown')} years
38
+ Company: {candidate.get('most_recent_company') or 'Not provided'}
39
+ Growth Velocity Score: {candidate.get('growth_velocity', 0.5):.2f} / 1.0
40
+
41
+ {gaps_text}
42
+
43
+ Provide your assessment:"""
44
+
45
+
46
+ async def generate_explanation(jd: dict, candidate: dict, gaps: list[dict]) -> str:
47
+ settings = get_settings()
48
+ client = AsyncGroq(api_key=settings.groq_api_key)
49
+
50
+ try:
51
+ response = await client.chat.completions.create(
52
+ model=settings.groq_model,
53
+ messages=[
54
+ {"role": "system", "content": SYSTEM_PROMPT},
55
+ {"role": "user", "content": _build_prompt(jd, candidate, gaps)},
56
+ ],
57
+ temperature=0.3,
58
+ max_tokens=400,
59
+ )
60
+ return response.choices[0].message.content.strip()
61
+ except Exception:
62
+ return _fallback_explanation(jd, candidate, gaps)
63
+
64
+
65
+ def _fallback_explanation(jd: dict, candidate: dict, gaps: list[dict]) -> str:
66
+ skill_gaps = [g["detail"] for g in gaps if g["type"] == "missing_skill"]
67
+ yoe_gaps = [g["detail"] for g in gaps if g["type"] == "yoe_gap"]
68
+
69
+ parts = []
70
+ skills_text = ", ".join(
71
+ (candidate.get("programming_languages") or [])[:5]
72
+ )
73
+ if skills_text:
74
+ parts.append(f"FIT ANALYSIS: Candidate brings experience in {skills_text}")
75
+ else:
76
+ parts.append("FIT ANALYSIS: Candidate profile available for review.")
77
+
78
+ if skill_gaps or yoe_gaps:
79
+ gap_list = (skill_gaps[:3] + yoe_gaps)[:3]
80
+ parts.append(f"KEY GAPS: Missing requirements include: {', '.join(gap_list)}.")
81
+ else:
82
+ parts.append("KEY GAPS: No critical gaps identified from structured data.")
83
+
84
+ return "\n".join(parts)
backend/src/routers/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .jds import router as jds_router
2
+ from .candidates import router as candidates_router
3
+ from .matching import router as matching_router
4
+
5
+ __all__ = ["jds_router", "candidates_router", "matching_router"]
backend/src/routers/candidates.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import pandas as pd
4
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy import select, func
7
+
8
+ from ..database import get_db
9
+ from ..models.candidate import Candidate
10
+ from ..schemas.candidate import CandidateResponse, UploadResponse, TaskStatusResponse
11
+ from ..workers.celery_app import celery_app
12
+ from ..workers.ingest import ingest_candidates_batch
13
+
14
+ router = APIRouter()
15
+
16
+ BATCH_SIZE = 100
17
+
18
+
19
+ @router.post("/upload", response_model=UploadResponse)
20
+ async def upload_candidates(file: UploadFile = File(...)):
21
+ content = await file.read()
22
+ filename = file.filename or ""
23
+
24
+ if filename.endswith(".csv"):
25
+ df = pd.read_csv(io.BytesIO(content))
26
+ rows = df.where(pd.notna(df), None).to_dict(orient="records")
27
+ elif filename.endswith(".json") or filename.endswith(".jsonl"):
28
+ try:
29
+ data = json.loads(content)
30
+ rows = data if isinstance(data, list) else [data]
31
+ except json.JSONDecodeError:
32
+ rows = [json.loads(line) for line in content.decode().splitlines() if line.strip()]
33
+ else:
34
+ raise HTTPException(status_code=400, detail="Only CSV or JSON files are supported")
35
+
36
+ if not rows:
37
+ raise HTTPException(status_code=400, detail="File contains no data rows")
38
+
39
+ task_ids = []
40
+ for i in range(0, len(rows), BATCH_SIZE):
41
+ batch = rows[i: i + BATCH_SIZE]
42
+ serializable = []
43
+ for r in batch:
44
+ clean = {}
45
+ for k, v in r.items():
46
+ if v is None or (isinstance(v, float) and pd.isna(v)):
47
+ clean[k] = None
48
+ else:
49
+ clean[k] = v
50
+ serializable.append(clean)
51
+ task = ingest_candidates_batch.delay(serializable)
52
+ task_ids.append(task.id)
53
+
54
+ return UploadResponse(
55
+ task_id=task_ids[0] if task_ids else "",
56
+ queued=len(rows),
57
+ message=f"Queued {len(rows)} candidates across {len(task_ids)} batches",
58
+ )
59
+
60
+
61
+ @router.get("/status/{task_id}", response_model=TaskStatusResponse)
62
+ async def task_status(task_id: str):
63
+ result = celery_app.AsyncResult(task_id)
64
+ return TaskStatusResponse(
65
+ task_id=task_id,
66
+ status=result.status,
67
+ result=result.result if result.ready() else None,
68
+ )
69
+
70
+
71
+ @router.get("/count")
72
+ async def candidate_count(db: AsyncSession = Depends(get_db)):
73
+ result = await db.execute(select(func.count()).select_from(Candidate))
74
+ count = result.scalar()
75
+ return {"count": count}
76
+
77
+
78
+ @router.get("/{candidate_id}", response_model=CandidateResponse)
79
+ async def get_candidate(candidate_id: str, db: AsyncSession = Depends(get_db)):
80
+ import uuid as _uuid
81
+ try:
82
+ cid = _uuid.UUID(candidate_id)
83
+ except ValueError:
84
+ raise HTTPException(status_code=400, detail="Invalid candidate ID")
85
+ result = await db.execute(select(Candidate).where(Candidate.id == cid))
86
+ cand = result.scalar_one_or_none()
87
+ if not cand:
88
+ raise HTTPException(status_code=404, detail="Candidate not found")
89
+ return cand
backend/src/routers/jds.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy import select
5
+
6
+ from ..database import get_db
7
+ from ..models.jd import JobDescription
8
+ from ..schemas.jd import JDCreate, JDResponse, JDListItem
9
+ from ..workers.ingest import ingest_jd
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("", response_model=JDResponse, status_code=201)
15
+ async def create_jd(payload: JDCreate, db: AsyncSession = Depends(get_db)):
16
+ jd = JobDescription(
17
+ id=uuid.uuid4(),
18
+ title=payload.title,
19
+ raw_text=payload.raw_text,
20
+ status="processing",
21
+ )
22
+ db.add(jd)
23
+ await db.commit()
24
+ await db.refresh(jd)
25
+
26
+ ingest_jd.delay(str(jd.id), payload.raw_text, payload.title)
27
+
28
+ return jd
29
+
30
+
31
+ @router.get("", response_model=list[JDListItem])
32
+ async def list_jds(db: AsyncSession = Depends(get_db)):
33
+ result = await db.execute(select(JobDescription).order_by(JobDescription.created_at.desc()).limit(50))
34
+ return result.scalars().all()
35
+
36
+
37
+ @router.get("/{jd_id}", response_model=JDResponse)
38
+ async def get_jd(jd_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
39
+ result = await db.execute(select(JobDescription).where(JobDescription.id == jd_id))
40
+ jd = result.scalar_one_or_none()
41
+ if not jd:
42
+ raise HTTPException(status_code=404, detail="JD not found")
43
+ return jd
backend/src/routers/matching.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select, delete
6
+
7
+ from ..database import get_db
8
+ from ..models.jd import JobDescription
9
+ from ..models.candidate import Candidate
10
+ from ..models.match_result import MatchResult
11
+ from ..schemas.match import MatchResponse, MatchedCandidate, ComponentScores, GapItem, CandidateDetailResponse, ReRankRequest
12
+ from ..matching.stage1 import stage1_retrieve
13
+ from ..matching.stage2 import stage2_rerank
14
+ from ..matching.llm_explainer import generate_explanation
15
+ from ..matching.scorer import rerank_with_weights
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ def _get_qdrant(request: Request):
21
+ return request.app.state.qdrant
22
+
23
+
24
+ async def _load_jd(jd_id: uuid.UUID, db: AsyncSession) -> JobDescription:
25
+ result = await db.execute(select(JobDescription).where(JobDescription.id == jd_id))
26
+ jd = result.scalar_one_or_none()
27
+ if not jd:
28
+ raise HTTPException(status_code=404, detail="JD not found")
29
+ if jd.status == "processing":
30
+ raise HTTPException(status_code=202, detail="JD is still being processed, try again shortly")
31
+ return jd
32
+
33
+
34
+ @router.post("/{jd_id}", response_model=MatchResponse)
35
+ async def trigger_match(
36
+ jd_id: uuid.UUID,
37
+ request: Request,
38
+ db: AsyncSession = Depends(get_db),
39
+ ):
40
+ jd = await _load_jd(jd_id, db)
41
+ qdrant = _get_qdrant(request)
42
+
43
+ jd_dict = {
44
+ "id": str(jd.id),
45
+ "title": jd.title,
46
+ "raw_text": jd.raw_text,
47
+ "required_skills": jd.required_skills or [],
48
+ "min_yoe": jd.min_yoe,
49
+ "max_yoe": jd.max_yoe,
50
+ "role_type": jd.role_type,
51
+ "engineer_type": jd.engineer_type,
52
+ "location": jd.location,
53
+ "remote_allowed": jd.remote_allowed,
54
+ }
55
+
56
+ shortlist = await stage1_retrieve(jd_dict, db, qdrant)
57
+ final_ranked = await stage2_rerank(jd_dict, shortlist)
58
+
59
+ await db.execute(delete(MatchResult).where(MatchResult.jd_id == jd_id))
60
+
61
+ match_records = []
62
+ for i, item in enumerate(final_ranked):
63
+ mr = MatchResult(
64
+ id=uuid.uuid4(),
65
+ jd_id=jd_id,
66
+ candidate_id=uuid.UUID(item["candidate_id"]),
67
+ rank=i + 1,
68
+ stage1_score=item.get("stage1_score", 0),
69
+ stage2_score=item.get("stage2_score"),
70
+ final_score=item.get("final_score", 0),
71
+ component_scores=item.get("component_scores", {}),
72
+ gaps=item.get("gaps", []),
73
+ )
74
+ match_records.append(mr)
75
+ db.add(mr)
76
+
77
+ await db.commit()
78
+
79
+ results = []
80
+ for i, item in enumerate(final_ranked):
81
+ results.append(
82
+ MatchedCandidate(
83
+ candidate_id=uuid.UUID(item["candidate_id"]),
84
+ rank=i + 1,
85
+ name=item.get("name"),
86
+ email=item.get("email"),
87
+ role_type=item.get("role_type"),
88
+ engineer_type=item.get("engineer_type"),
89
+ years_of_experience=item.get("years_of_experience"),
90
+ most_recent_company=item.get("most_recent_company"),
91
+ parsed_summary=item.get("parsed_summary"),
92
+ programming_languages=item.get("programming_languages") or [],
93
+ growth_velocity=item.get("growth_velocity", 0.5),
94
+ stage1_score=item.get("stage1_score", 0),
95
+ stage2_score=item.get("stage2_score"),
96
+ final_score=item.get("final_score", 0),
97
+ component_scores=ComponentScores(**item.get("component_scores", {})),
98
+ gaps=[GapItem(**g) for g in item.get("gaps", [])],
99
+ )
100
+ )
101
+
102
+ return MatchResponse(
103
+ jd_id=jd_id,
104
+ jd_title=jd.title,
105
+ jd_quality=jd.jd_quality or {},
106
+ total_matched=len(results),
107
+ results=results,
108
+ weights_used={"semantic": 0.20, "skill": 0.35, "yoe": 0.15, "company": 0.10, "growth": 0.10, "education": 0.10},
109
+ )
110
+
111
+
112
+ @router.get("/{jd_id}", response_model=MatchResponse)
113
+ async def get_match_results(jd_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
114
+ jd = await _load_jd(jd_id, db)
115
+
116
+ result = await db.execute(
117
+ select(MatchResult, Candidate)
118
+ .join(Candidate, MatchResult.candidate_id == Candidate.id)
119
+ .where(MatchResult.jd_id == jd_id)
120
+ .order_by(MatchResult.rank)
121
+ )
122
+ rows = result.all()
123
+
124
+ if not rows:
125
+ raise HTTPException(status_code=404, detail="No match results found. Run POST /api/match/{jd_id} first.")
126
+
127
+ results = []
128
+ for mr, cand in rows:
129
+ results.append(
130
+ MatchedCandidate(
131
+ candidate_id=cand.id,
132
+ rank=mr.rank or 0,
133
+ name=cand.name,
134
+ email=cand.email,
135
+ role_type=cand.role_type,
136
+ engineer_type=cand.engineer_type,
137
+ years_of_experience=cand.years_of_experience,
138
+ most_recent_company=cand.most_recent_company,
139
+ parsed_summary=cand.parsed_summary,
140
+ programming_languages=cand.programming_languages or [],
141
+ growth_velocity=cand.growth_velocity,
142
+ stage1_score=mr.stage1_score,
143
+ stage2_score=mr.stage2_score,
144
+ final_score=mr.final_score,
145
+ component_scores=ComponentScores(**(mr.component_scores or {})),
146
+ gaps=[GapItem(**g) for g in (mr.gaps or [])],
147
+ )
148
+ )
149
+
150
+ return MatchResponse(
151
+ jd_id=jd_id,
152
+ jd_title=jd.title,
153
+ jd_quality=jd.jd_quality or {},
154
+ total_matched=len(results),
155
+ results=results,
156
+ )
157
+
158
+
159
+ @router.get("/{jd_id}/{candidate_id}", response_model=CandidateDetailResponse)
160
+ async def get_candidate_detail(
161
+ jd_id: uuid.UUID,
162
+ candidate_id: uuid.UUID,
163
+ db: AsyncSession = Depends(get_db),
164
+ ):
165
+ jd = await _load_jd(jd_id, db)
166
+
167
+ mr_result = await db.execute(
168
+ select(MatchResult).where(
169
+ MatchResult.jd_id == jd_id,
170
+ MatchResult.candidate_id == candidate_id,
171
+ )
172
+ )
173
+ mr = mr_result.scalar_one_or_none()
174
+ if not mr:
175
+ raise HTTPException(status_code=404, detail="Match result not found for this JD/candidate pair")
176
+
177
+ cand_result = await db.execute(select(Candidate).where(Candidate.id == candidate_id))
178
+ cand = cand_result.scalar_one_or_none()
179
+ if not cand:
180
+ raise HTTPException(status_code=404, detail="Candidate not found")
181
+
182
+ if not mr.explanation:
183
+ jd_dict = {
184
+ "id": str(jd.id),
185
+ "title": jd.title,
186
+ "raw_text": jd.raw_text,
187
+ "required_skills": jd.required_skills or [],
188
+ "min_yoe": jd.min_yoe,
189
+ "engineer_type": jd.engineer_type,
190
+ "location": jd.location,
191
+ "remote_allowed": jd.remote_allowed,
192
+ }
193
+ cand_dict = {
194
+ "parsed_summary": cand.parsed_summary,
195
+ "parsed_skills": cand.parsed_skills,
196
+ "years_of_experience": cand.years_of_experience,
197
+ "programming_languages": cand.programming_languages or [],
198
+ "backend_frameworks": cand.backend_frameworks or [],
199
+ "frontend_technologies": cand.frontend_technologies or [],
200
+ "most_recent_company": cand.most_recent_company,
201
+ "growth_velocity": cand.growth_velocity,
202
+ }
203
+ explanation = await generate_explanation(jd_dict, cand_dict, mr.gaps or [])
204
+ mr.explanation = explanation
205
+ mr.explanation_generated_at = datetime.now(timezone.utc)
206
+ await db.commit()
207
+
208
+ return CandidateDetailResponse(
209
+ jd_id=jd_id,
210
+ candidate_id=candidate_id,
211
+ rank=mr.rank,
212
+ final_score=mr.final_score,
213
+ component_scores=ComponentScores(**(mr.component_scores or {})),
214
+ gaps=[GapItem(**g) for g in (mr.gaps or [])],
215
+ explanation=mr.explanation,
216
+ candidate={
217
+ "name": cand.name,
218
+ "email": cand.email,
219
+ "role_type": cand.role_type,
220
+ "engineer_type": cand.engineer_type,
221
+ "years_of_experience": cand.years_of_experience,
222
+ "most_recent_company": cand.most_recent_company,
223
+ "parsed_summary": cand.parsed_summary,
224
+ "parsed_skills": cand.parsed_skills,
225
+ "parsed_work_experience": cand.parsed_work_experience or [],
226
+ "programming_languages": cand.programming_languages or [],
227
+ "backend_frameworks": cand.backend_frameworks or [],
228
+ "gen_ai_experience": cand.gen_ai_experience,
229
+ "growth_velocity": cand.growth_velocity,
230
+ "looking_for": cand.looking_for,
231
+ "open_to_working_at": cand.open_to_working_at,
232
+ "is_actively_or_passively_looking": cand.is_actively_or_passively_looking,
233
+ "most_recent_company_is_funded": cand.most_recent_company_is_funded,
234
+ "most_recent_company_is_product_company": cand.most_recent_company_is_product_company,
235
+ "most_recent_company_total_funding": cand.most_recent_company_total_funding,
236
+ },
237
+ jd={
238
+ "title": jd.title,
239
+ "required_skills": jd.required_skills or [],
240
+ "min_yoe": jd.min_yoe,
241
+ "role_type": jd.role_type,
242
+ "engineer_type": jd.engineer_type,
243
+ "location": jd.location,
244
+ },
245
+ )
246
+
247
+
248
+ @router.post("/{jd_id}/rerank", response_model=MatchResponse)
249
+ async def rerank_results(
250
+ jd_id: uuid.UUID,
251
+ payload: ReRankRequest,
252
+ db: AsyncSession = Depends(get_db),
253
+ ):
254
+ jd = await _load_jd(jd_id, db)
255
+
256
+ result = await db.execute(
257
+ select(MatchResult, Candidate)
258
+ .join(Candidate, MatchResult.candidate_id == Candidate.id)
259
+ .where(MatchResult.jd_id == jd_id)
260
+ .order_by(MatchResult.rank)
261
+ )
262
+ rows = result.all()
263
+
264
+ if not rows:
265
+ raise HTTPException(status_code=404, detail="No match results found.")
266
+
267
+ items = []
268
+ for mr, cand in rows:
269
+ items.append({
270
+ "candidate_id": str(cand.id),
271
+ "name": cand.name,
272
+ "email": cand.email,
273
+ "role_type": cand.role_type,
274
+ "engineer_type": cand.engineer_type,
275
+ "years_of_experience": cand.years_of_experience,
276
+ "most_recent_company": cand.most_recent_company,
277
+ "parsed_summary": cand.parsed_summary,
278
+ "programming_languages": cand.programming_languages or [],
279
+ "growth_velocity": cand.growth_velocity,
280
+ "stage1_score": mr.stage1_score,
281
+ "stage2_score": mr.stage2_score,
282
+ "final_score": mr.final_score,
283
+ "component_scores": mr.component_scores or {},
284
+ "gaps": mr.gaps or [],
285
+ })
286
+
287
+ reranked = rerank_with_weights(items, payload.weights)
288
+
289
+ results = [
290
+ MatchedCandidate(
291
+ candidate_id=uuid.UUID(item["candidate_id"]),
292
+ rank=item["rank"],
293
+ name=item.get("name"),
294
+ email=item.get("email"),
295
+ role_type=item.get("role_type"),
296
+ engineer_type=item.get("engineer_type"),
297
+ years_of_experience=item.get("years_of_experience"),
298
+ most_recent_company=item.get("most_recent_company"),
299
+ parsed_summary=item.get("parsed_summary"),
300
+ programming_languages=item.get("programming_languages") or [],
301
+ growth_velocity=item.get("growth_velocity", 0.5),
302
+ stage1_score=item.get("stage1_score", 0),
303
+ stage2_score=item.get("stage2_score"),
304
+ final_score=item.get("final_score", 0),
305
+ component_scores=ComponentScores(**(item.get("component_scores") or {})),
306
+ gaps=[GapItem(**g) for g in item.get("gaps", [])],
307
+ )
308
+ for item in reranked
309
+ ]
310
+
311
+ return MatchResponse(
312
+ jd_id=jd_id,
313
+ jd_title=jd.title,
314
+ jd_quality=jd.jd_quality or {},
315
+ total_matched=len(results),
316
+ results=results,
317
+ weights_used=payload.weights,
318
+ )
backend/src/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # schemas package
backend/src/schemas/candidate.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import UUID
2
+ from typing import Any
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class CandidateResponse(BaseModel):
7
+ id: UUID
8
+ name: str | None = None
9
+ email: str | None = None
10
+ role_type: str | None = None
11
+ engineer_type: str | None = None
12
+ years_of_experience: float | None = None
13
+ most_recent_company: str | None = None
14
+ parsed_summary: str | None = None
15
+ parsed_skills: str | None = None
16
+ programming_languages: list[str] = []
17
+ backend_frameworks: list[str] = []
18
+ frontend_technologies: list[str] = []
19
+ growth_velocity: float = 0.5
20
+ gen_ai_experience: bool | None = None
21
+ looking_for: str | None = None
22
+ open_to_working_at: str | None = None
23
+ is_actively_or_passively_looking: str | None = None
24
+
25
+ model_config = {"from_attributes": True}
26
+
27
+
28
+ class UploadResponse(BaseModel):
29
+ task_id: str
30
+ queued: int
31
+ message: str
32
+
33
+
34
+ class TaskStatusResponse(BaseModel):
35
+ task_id: str
36
+ status: str
37
+ result: Any | None = None
backend/src/schemas/jd.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import UUID
2
+ from datetime import datetime
3
+ from typing import Any
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class JDCreate(BaseModel):
8
+ title: str
9
+ raw_text: str
10
+
11
+
12
+ class JDResponse(BaseModel):
13
+ id: UUID
14
+ title: str
15
+ raw_text: str
16
+ status: str
17
+ min_yoe: float | None = None
18
+ role_type: str | None = None
19
+ engineer_type: str | None = None
20
+ location: str | None = None
21
+ required_skills: list[str] = []
22
+ jd_quality: dict[str, Any] = {}
23
+ created_at: datetime
24
+
25
+ model_config = {"from_attributes": True}
26
+
27
+
28
+ class JDListItem(BaseModel):
29
+ id: UUID
30
+ title: str
31
+ status: str
32
+ jd_quality: dict[str, Any] = {}
33
+ created_at: datetime
34
+
35
+ model_config = {"from_attributes": True}
backend/src/schemas/match.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import UUID
2
+ from typing import Any
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ComponentScores(BaseModel):
7
+ semantic: float = 0.0
8
+ skill: float = 0.0
9
+ yoe: float = 0.0
10
+ company: float = 0.0
11
+ growth: float = 0.0
12
+ education: float = 0.0
13
+
14
+
15
+ class GapItem(BaseModel):
16
+ type: str
17
+ detail: str
18
+ mitigated_by_remote: bool | None = None
19
+
20
+
21
+ class MatchedCandidate(BaseModel):
22
+ candidate_id: UUID
23
+ rank: int
24
+ name: str | None = None
25
+ email: str | None = None
26
+ role_type: str | None = None
27
+ engineer_type: str | None = None
28
+ years_of_experience: float | None = None
29
+ most_recent_company: str | None = None
30
+ parsed_summary: str | None = None
31
+ programming_languages: list[str] = []
32
+ growth_velocity: float = 0.5
33
+ stage1_score: float
34
+ stage2_score: float | None = None
35
+ final_score: float
36
+ component_scores: ComponentScores
37
+ gaps: list[GapItem] = []
38
+
39
+
40
+ class MatchResponse(BaseModel):
41
+ jd_id: UUID
42
+ jd_title: str
43
+ jd_quality: dict[str, Any] = {}
44
+ total_matched: int
45
+ results: list[MatchedCandidate]
46
+ weights_used: dict[str, float] = {}
47
+
48
+
49
+ class CandidateDetailResponse(BaseModel):
50
+ jd_id: UUID
51
+ candidate_id: UUID
52
+ rank: int | None = None
53
+ final_score: float
54
+ component_scores: ComponentScores
55
+ gaps: list[GapItem] = []
56
+ explanation: str | None = None
57
+ candidate: dict[str, Any] = {}
58
+ jd: dict[str, Any] = {}
59
+
60
+
61
+ class ReRankRequest(BaseModel):
62
+ weights: dict[str, float] = Field(
63
+ default={
64
+ "semantic": 0.20,
65
+ "skill": 0.35,
66
+ "yoe": 0.15,
67
+ "company": 0.10,
68
+ "growth": 0.10,
69
+ "education": 0.10,
70
+ }
71
+ )