Spaces:
Sleeping
Sleeping
Brevo + faster drafts + adaptive chart + fuzzy lookup
Browse files
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 |
-
|
|
|
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
"
|
| 215 |
-
"
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 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 |
-
|
| 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:
|