HR-ATS-Portal/backend/talent/matching.py

125 lines
4.6 KiB
Python

"""Flags sourced LinkedIn profiles that are already applicants in the ATS.
A sourced profile and a CV describe the same person when they carry the same
/in/<slug>. The slug is persisted on application rows as the CV is processed
(inbox_messages.linkedin_slug, manual_upload_candidate.linkedin_slug); rows
that predate those columns are backfilled lazily here in bounded batches, so
the matching converges over normal use without a migration script.
The annotation rides on the profile list/detail payloads as `already_applied`:
{"source": "inbox"|"manual", "status", "job_post_id", "candidate",
"applied_at", "same_job": bool, "applications": N} # or null
When the person applied to several jobs, the application for the profile's own
job wins the summary slot and `same_job` says which case the UI is looking at.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.models import Inbox_Messages
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from linkedin_utils import primary_slug_from_text, slug_from_url
BACKFILL_BATCH = 200
async def _backfill_slugs(session: AsyncSession) -> None:
"""Scan a bounded batch of never-scanned CVs (linkedin_slug IS NULL)."""
changed = False
inbox_q = (
select(Inbox_Messages)
.where(
Inbox_Messages.linkedin_slug.is_(None),
Inbox_Messages.resume_text.is_not(None),
Inbox_Messages.resume_text != "",
)
.limit(BACKFILL_BATCH)
)
for row in (await session.execute(inbox_q)).scalars().all():
row.linkedin_slug = primary_slug_from_text(row.resume_text)
session.add(row)
changed = True
manual_q = (
select(Manual_UPLOAD_CANDIDATE)
.where(
Manual_UPLOAD_CANDIDATE.linkedin_slug.is_(None),
Manual_UPLOAD_CANDIDATE.full_text != "",
)
.limit(BACKFILL_BATCH)
)
for row in (await session.execute(manual_q)).scalars().all():
row.linkedin_slug = primary_slug_from_text(row.full_text)
session.add(row)
changed = True
if changed:
await session.commit()
async def annotate_applications(session: AsyncSession, profiles: list[dict]) -> list[dict]:
"""Attach `already_applied` to serialized profile dicts, matched by slug."""
for profile in profiles:
profile["already_applied"] = None
slug_map: dict[str, list[dict]] = {}
for profile in profiles:
slug = slug_from_url(profile.get("linkedin_url"))
if slug:
slug_map.setdefault(slug, []).append(profile)
if not slug_map:
return profiles
await _backfill_slugs(session)
matches: dict[str, list[dict]] = {}
inbox_q = select(
Inbox_Messages.linkedin_slug,
Inbox_Messages.application_status,
Inbox_Messages.assigned_job_post_id,
Inbox_Messages.message_from,
Inbox_Messages.created_at,
).where(Inbox_Messages.linkedin_slug.in_(list(slug_map)))
for slug, status, job_id, sender, created in (await session.execute(inbox_q)).all():
matches.setdefault(slug, []).append({
"source": "inbox",
"status": (getattr(status, "value", status) or None),
"job_post_id": str(job_id) if job_id else None,
"candidate": sender or None,
"applied_at": created.isoformat() if created else None,
})
manual_q = select(
Manual_UPLOAD_CANDIDATE.linkedin_slug,
Manual_UPLOAD_CANDIDATE.status,
Manual_UPLOAD_CANDIDATE.job_post_id,
Manual_UPLOAD_CANDIDATE.candidate_name,
Manual_UPLOAD_CANDIDATE.created_at,
).where(Manual_UPLOAD_CANDIDATE.linkedin_slug.in_(list(slug_map)))
for slug, status, job_id, name, created in (await session.execute(manual_q)).all():
matches.setdefault(slug, []).append({
"source": "manual",
"status": (status or "").strip() or None,
"job_post_id": str(job_id) if job_id else None,
"candidate": (name or "").strip() or None,
"applied_at": created.isoformat() if created else None,
})
for slug, slug_profiles in slug_map.items():
found = matches.get(slug)
if not found:
continue
for profile in slug_profiles:
job_id = profile.get("job_post_id")
same = [m for m in found if m["job_post_id"] and m["job_post_id"] == job_id]
best = same[0] if same else found[0]
profile["already_applied"] = {
**best,
"same_job": bool(same),
"applications": len(found),
}
return profiles