Ashgen12 commited on
Commit
015d638
·
verified ·
1 Parent(s): 5e03385

Brevo + faster drafts + adaptive chart + fuzzy lookup

Browse files
Files changed (1) hide show
  1. backend/services/candidate_service.py +79 -19
backend/services/candidate_service.py CHANGED
@@ -161,12 +161,52 @@ class CandidateService:
161
  return self._dedupe_candidates(rows, limit)
162
 
163
  def get_candidate(self, db: Session, candidate_id: str) -> Candidate | None:
 
 
 
 
 
 
 
 
 
164
  clean_id = (candidate_id or "").strip()
165
  if not clean_id:
166
  return None
167
- return db.execute(
 
168
  select(Candidate).where(Candidate.external_id == clean_id)
169
  ).scalar_one_or_none()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
  def build_profile_payload(self, candidate: Candidate) -> dict[str, Any]:
172
  skills = [token.strip() for token in (candidate.skills_csv or "").split(",") if token.strip()]
@@ -207,22 +247,40 @@ class CandidateService:
207
 
208
  years = float(candidate.years_experience or 0)
209
  skill_groups = self._classify_skills(skills)
210
- chart = {
211
- "type": "radar",
212
- "title": "Skill profile",
213
- "xKey": "name",
214
- "yKey": "score",
215
- "data": [
216
- {"name": label, "score": value}
217
- for label, value in [
218
- ("Backend", skill_groups.get("backend", 0)),
219
- ("Frontend", skill_groups.get("frontend", 0)),
220
- ("Cloud", skill_groups.get("cloud", 0)),
221
- ("Data/ML", skill_groups.get("data", 0)),
222
- ("Years", round(min(years, 10), 1)),
223
- ]
224
- ],
225
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
  skills_chip_line = (
228
  " ".join(f"`{skill}`" for skill in skills) if skills else "_No skills detected._"
@@ -254,12 +312,14 @@ class CandidateService:
254
  )
255
  c1_response = f"<content><custom_markdown>{safe_md}</custom_markdown></content>"
256
 
257
- return {
258
  "summary": summary_md,
259
  "cards": [card],
260
- "chart": chart,
261
  "c1_response": c1_response,
262
  }
 
 
 
263
 
264
  def get_by_external_ids(self, db: Session, external_ids: list[str]) -> list[Candidate]:
265
  if not external_ids:
 
161
  return self._dedupe_candidates(rows, limit)
162
 
163
  def get_candidate(self, db: Session, candidate_id: str) -> Candidate | None:
164
+ """Look up a candidate by external_id, with two fallbacks for robustness.
165
+
166
+ Cards rendered from the search result use the Qdrant ``candidate_id``
167
+ which can drift from the SQLite ``external_id`` (e.g. when the bootstrap
168
+ loader writes one shape and ad-hoc uploads write another). We:
169
+ 1. Match the exact ``external_id``
170
+ 2. Match a substring (handles ``_resume`` / version suffixes)
171
+ 3. Match the candidate name slug embedded in the id (e.g. ``alex_carter``)
172
+ """
173
  clean_id = (candidate_id or "").strip()
174
  if not clean_id:
175
  return None
176
+
177
+ exact = db.execute(
178
  select(Candidate).where(Candidate.external_id == clean_id)
179
  ).scalar_one_or_none()
180
+ if exact is not None:
181
+ return exact
182
+
183
+ # Substring fallback — handles trailing/leading suffixes like _resume.
184
+ substr = db.execute(
185
+ select(Candidate)
186
+ .where(Candidate.external_id.ilike(f"%{clean_id}%"))
187
+ .order_by(Candidate.updated_at.desc())
188
+ .limit(1)
189
+ ).scalar_one_or_none()
190
+ if substr is not None:
191
+ return substr
192
+
193
+ # Name-slug fallback — pull "alex_carter" out of "ref_alex_carter_resume"
194
+ # and try to match a candidate full_name that contains both tokens.
195
+ slug_tokens = [
196
+ tok.lower()
197
+ for tok in re.split(r"[^a-zA-Z0-9]+", clean_id)
198
+ if tok and tok.lower() not in {"ref", "resume", "cv", "pdf"} and not tok.isdigit()
199
+ ]
200
+ if not slug_tokens:
201
+ return None
202
+
203
+ like_pattern = "%" + "%".join(slug_tokens) + "%"
204
+ return db.execute(
205
+ select(Candidate)
206
+ .where(Candidate.full_name.ilike(like_pattern))
207
+ .order_by(Candidate.updated_at.desc())
208
+ .limit(1)
209
+ ).scalar_one_or_none()
210
 
211
  def build_profile_payload(self, candidate: Candidate) -> dict[str, Any]:
212
  skills = [token.strip() for token in (candidate.skills_csv or "").split(",") if token.strip()]
 
247
 
248
  years = float(candidate.years_experience or 0)
249
  skill_groups = self._classify_skills(skills)
250
+ # Build the chart axes dynamically — only include axes with data so a
251
+ # non-tech profile (e.g. sales / civil engineering) doesn't get rendered
252
+ # as five empty rings.
253
+ candidate_axes = [
254
+ ("Backend", skill_groups.get("backend", 0)),
255
+ ("Frontend", skill_groups.get("frontend", 0)),
256
+ ("Cloud", skill_groups.get("cloud", 0)),
257
+ ("Data/ML", skill_groups.get("data", 0)),
258
+ ]
259
+ non_zero = [(label, value) for label, value in candidate_axes if value > 0]
260
+ if years > 0:
261
+ non_zero.append(("Years", round(min(years, 10), 1)))
262
+
263
+ if len(non_zero) >= 3:
264
+ chart = {
265
+ "type": "radar",
266
+ "title": "Skill profile",
267
+ "xKey": "name",
268
+ "yKey": "score",
269
+ "data": [{"name": label, "score": value} for label, value in non_zero],
270
+ }
271
+ elif non_zero:
272
+ # Two or fewer signals — radar doesn't read well, switch to bar.
273
+ chart = {
274
+ "type": "bar",
275
+ "title": "Profile signals",
276
+ "xKey": "name",
277
+ "yKey": "score",
278
+ "data": [{"name": label, "score": value} for label, value in non_zero],
279
+ }
280
+ else:
281
+ # Nothing to plot — drop the chart entirely so the candidate card
282
+ # isn't cluttered with empty axes.
283
+ chart = None
284
 
285
  skills_chip_line = (
286
  " ".join(f"`{skill}`" for skill in skills) if skills else "_No skills detected._"
 
312
  )
313
  c1_response = f"<content><custom_markdown>{safe_md}</custom_markdown></content>"
314
 
315
+ payload: dict[str, Any] = {
316
  "summary": summary_md,
317
  "cards": [card],
 
318
  "c1_response": c1_response,
319
  }
320
+ if chart is not None:
321
+ payload["chart"] = chart
322
+ return payload
323
 
324
  def get_by_external_ids(self, db: Session, external_ids: list[str]) -> list[Candidate]:
325
  if not external_ids: