216 lines
8.2 KiB
Python
216 lines
8.2 KiB
Python
"""CV Bank Taskiq tasks — profile backfill and job-opening rank.
|
|
|
|
Worker: taskiq worker taskiq_management.broker_setup:broker job.candidate.bank_tasks
|
|
|
|
Two jobs live here, both about the bank being useful rather than merely stored:
|
|
|
|
cvbank.backfill_profiles one-off, for CVs banked before extraction existed
|
|
cvbank.rank_for_job fired when a job opens, so the bank is offered up
|
|
instead of waiting to be remembered
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
from db_setup import session_scope
|
|
from taskiq_management.broker_setup import MAX_RETRIES, RETRY_DELAY, broker
|
|
from taskiq_management.middleware import PermanentTaskError
|
|
|
|
logger = logging.getLogger("cvbank.tasks")
|
|
|
|
# One agent call per CV, so a backfill of a large bank is paced across runs
|
|
# rather than fired as one unbounded burst.
|
|
BACKFILL_BATCH = 25
|
|
|
|
# 03:00 daily. The sweep only flags, so the exact hour does not matter; off-peak
|
|
# just keeps it away from the scoring workload.
|
|
RETENTION_SWEEP_CRON = os.getenv("CV_BANK_RETENTION_CRON", "0 3 * * *")
|
|
|
|
|
|
@broker.task(
|
|
task_name="cvbank.backfill_profiles",
|
|
retry_on_error=True,
|
|
max_retries=MAX_RETRIES,
|
|
delay=RETRY_DELAY,
|
|
)
|
|
async def backfill_bank_profiles(limit: int = BACKFILL_BATCH) -> dict:
|
|
"""Extract skills/title/company/years for CVs banked before migration 029.
|
|
|
|
Re-runnable: rows are selected by "has no extraction yet", so a finished
|
|
bank returns scanned=0 and the task becomes a no-op. Returns `remaining`
|
|
so a caller can decide whether to enqueue another batch.
|
|
"""
|
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
|
from job.candidate.views import extract_bank_profile_from_cv
|
|
|
|
updated = 0
|
|
failed = 0
|
|
async with session_scope() as session:
|
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(
|
|
session, limit=max(1, int(limit or BACKFILL_BATCH)),
|
|
)
|
|
for row in rows:
|
|
# extract_bank_profile_from_cv never raises, but a bad row must not
|
|
# cost the whole batch either.
|
|
try:
|
|
profile = await extract_bank_profile_from_cv(row.full_text)
|
|
except Exception:
|
|
logger.exception("bank profile backfill failed id=%s", row.id)
|
|
failed += 1
|
|
continue
|
|
if not any(profile.get(k) for k in ("skills", "current_position", "current_company")):
|
|
continue
|
|
await Manual_UPLOAD_CANDIDATE.set_bank_profile(session, row.id, profile)
|
|
updated += 1
|
|
remaining = len(
|
|
await Manual_UPLOAD_CANDIDATE.list_bank_needing_profile(session, limit=1)
|
|
)
|
|
return {"scanned": len(rows), "updated": updated, "failed": failed, "remaining": remaining}
|
|
|
|
|
|
@broker.task(
|
|
task_name="cvbank.rank_for_job",
|
|
retry_on_error=True,
|
|
max_retries=MAX_RETRIES,
|
|
delay=RETRY_DELAY,
|
|
)
|
|
async def rank_bank_for_job(job_post_id: str) -> dict:
|
|
"""Score every banked CV against a newly opened job — tier 1, free.
|
|
|
|
Deterministic keyword overlap only. No LLM call, so this runs over the
|
|
whole bank on every job opening without a bill; the paid ATS score happens
|
|
later and only for the handful a recruiter shortlists.
|
|
"""
|
|
from job.candidate.models import CvBankMatches, Manual_UPLOAD_CANDIDATE
|
|
from job.job_post.models import JobPosts
|
|
from matching.ranking import rank_bank_row
|
|
|
|
if not job_post_id or not str(job_post_id).strip():
|
|
raise PermanentTaskError("job_post_id is required")
|
|
job_post_id = str(job_post_id).strip()
|
|
|
|
async with session_scope() as session:
|
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
|
if job is None or job.is_deleted:
|
|
raise PermanentTaskError("job post missing or deleted")
|
|
job_fields = {
|
|
"title": job.title,
|
|
"requirements": job.requirements,
|
|
"optional_skills": job.optional_skills,
|
|
}
|
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_for_ranking(session)
|
|
scores = [(row.id, rank_bank_row(job_fields, row)) for row in rows]
|
|
await CvBankMatches.replace_for_job(session, job.id, scores)
|
|
|
|
threshold = _suggest_threshold()
|
|
strong = [s for _, s in scores if s >= threshold]
|
|
if strong:
|
|
await _notify_owner(job_post_id, len(strong))
|
|
return {"ranked": len(scores), "above_threshold": len(strong)}
|
|
|
|
|
|
@broker.task(
|
|
task_name="cvbank.sweep_expired",
|
|
schedule=[{"cron": RETENTION_SWEEP_CRON}],
|
|
)
|
|
async def sweep_expired_bank_cvs() -> dict:
|
|
"""Flag banked CVs past their retention window — nightly.
|
|
|
|
Flags, never deletes. These are resumes a person sent us: dropping them on
|
|
a timer with no record would be worse than holding them, and a wrongly
|
|
configured window would silently destroy the whole bank. A human decides,
|
|
the sweep only makes the decision unavoidable.
|
|
|
|
Expired rows are already excluded from ranking (list_bank_for_ranking), so
|
|
nothing is being surfaced to recruiters in the meantime.
|
|
"""
|
|
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
|
|
|
async with session_scope() as session:
|
|
rows = await Manual_UPLOAD_CANDIDATE.list_bank_expired(session)
|
|
for row in rows:
|
|
logger.info(
|
|
"cv-bank retention expired id=%s banked_at=%s expired_at=%s",
|
|
row.id,
|
|
row.created_at.isoformat() if row.created_at else None,
|
|
row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
|
)
|
|
if rows:
|
|
await _notify_retention_review(len(rows))
|
|
return {"expired": len(rows)}
|
|
|
|
|
|
async def _notify_retention_review(count: int) -> None:
|
|
"""Tell whoever banked the CVs that the window has run out.
|
|
|
|
Best effort — the log line above is the durable record.
|
|
"""
|
|
import uuid as _uuid
|
|
|
|
try:
|
|
from notifications.models import Notifications
|
|
from users.models import Users
|
|
|
|
recipient = os.getenv("CV_BANK_RETENTION_NOTIFY_EMAIL", "").strip().lower()
|
|
if not recipient:
|
|
return
|
|
async with session_scope() as session:
|
|
user = await Users.get_user_by_email(session, recipient)
|
|
if user is None:
|
|
return
|
|
await Notifications.insert_notification(session, {
|
|
"user_id": _uuid.UUID(str(user.id)),
|
|
"kind": "system",
|
|
"title": "CV Bank retention review",
|
|
"body": (
|
|
f"{count} stored CV{'s' if count != 1 else ''} passed the retention "
|
|
"window and need to be kept with a reason or deleted."
|
|
),
|
|
"link_path": "/cvbank",
|
|
})
|
|
except Exception:
|
|
logger.exception("cv-bank retention notification failed")
|
|
|
|
|
|
def _suggest_threshold() -> int:
|
|
return int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
|
|
|
|
|
|
async def _notify_owner(job_post_id: str, count: int) -> None:
|
|
"""Tell the job's recruiter the bank already holds plausible candidates.
|
|
|
|
This is the whole point of ranking on job creation: without it the bank
|
|
only gets searched by someone who remembers it exists.
|
|
|
|
Best effort — a missing notification must never fail the ranking that has
|
|
already been persisted.
|
|
"""
|
|
import uuid as _uuid
|
|
|
|
try:
|
|
from job.job_post.models import JobPosts
|
|
from notifications.models import Notifications
|
|
|
|
async with session_scope() as session:
|
|
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
|
if job is None:
|
|
return
|
|
raw = getattr(job, "current_recruiter_id", None) or getattr(job, "created_by", None)
|
|
if not raw:
|
|
return
|
|
await Notifications.insert_notification(session, {
|
|
"user_id": _uuid.UUID(str(raw)),
|
|
"kind": "application",
|
|
"title": "CVs in the bank match this job",
|
|
"body": (
|
|
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
|
f"{job.title}. Open the CV Bank to review them."
|
|
),
|
|
"link_path": f"/cvbank?job={job_post_id}",
|
|
"job_post_id": job.id,
|
|
})
|
|
except Exception:
|
|
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)
|