Auto-score inbox CVs against matched jobs - no manual upload needed
The decoded attachment is already the CV and the job post is already the JD, so scoring now fires from the data we have: - After the agent matches an inbox message, score_message_against_job runs inline in the match task - against the assigned job if one is set, else the top suggested job. Failures log and never fail the match. - When a recruiter assigns a job post, the new inbox.score_message task is queued in the background so the request never waits on OpenAI. Idempotent by (message, job): an already-completed score is never paid for twice. Rows are attributed to the job post owner since background tasks have no request user. CV Import and the profile Score-with-ATS button remain as manual fallbacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Dashboard_Wiring
parent
7c593d32ce
commit
da61fe2a3a
|
|
@ -1,10 +1,13 @@
|
||||||
"""Inbox Taskiq tasks — CV → job-post matching."""
|
"""Inbox Taskiq tasks — CV → job-post matching and ATS scoring."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime,timezone
|
from datetime import datetime,timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from agent.execute_agent import run_agent
|
from agent.execute_agent import run_agent
|
||||||
from db_setup import session_scope
|
from db_setup import session_scope
|
||||||
from employment_agent.execute_agent import run_employment_agent
|
from employment_agent.execute_agent import run_employment_agent
|
||||||
|
|
@ -19,6 +22,58 @@ logger=logging.getLogger("inbox.tasks")
|
||||||
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
|
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
|
||||||
|
|
||||||
|
|
||||||
|
async def score_message_against_job(record_id:str,job_id:str) -> dict:
|
||||||
|
"""ATS-score one inbox CV against one job post — the no-upload path.
|
||||||
|
|
||||||
|
The decoded attachment already on disk is the CV; the job post in the
|
||||||
|
database is the JD. Idempotent: a (message, job) pair with a completed
|
||||||
|
score is never paid for twice; re-runs are a no-op.
|
||||||
|
"""
|
||||||
|
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
|
||||||
|
# import here would be circular.
|
||||||
|
from job.candidate.models import Candidates
|
||||||
|
from job.candidate.views import CandidateScoring
|
||||||
|
|
||||||
|
mid=Candidates._as_uuid(record_id)
|
||||||
|
jid=Candidates._as_uuid(job_id)
|
||||||
|
if mid is None or jid is None:
|
||||||
|
raise PermanentTaskError("record_id and job_id must be uuids")
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
existing=await session.execute(
|
||||||
|
select(Candidates).where(
|
||||||
|
Candidates.inbox_message_id==mid,
|
||||||
|
Candidates.job_id==jid,
|
||||||
|
Candidates.status=="completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalars().first() is not None:
|
||||||
|
return {"status":"already_scored"}
|
||||||
|
job=await JobPosts.get_job_post_by_id(session,job_id)
|
||||||
|
if job is None or job.is_deleted:
|
||||||
|
raise PermanentTaskError("job post missing or deleted")
|
||||||
|
service=CandidateScoring(session=session)
|
||||||
|
try:
|
||||||
|
# Attribute the rows to the job's owner — there is no request user
|
||||||
|
# in a background task.
|
||||||
|
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
|
||||||
|
except HTTPException as exc:
|
||||||
|
# 400/404 from score_inbox are permanent (no attachment, bad ids);
|
||||||
|
# retrying cannot fix them.
|
||||||
|
raise PermanentTaskError(str(exc.detail)) from exc
|
||||||
|
return {"status":"scored","results":len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(
|
||||||
|
task_name="inbox.score_message",
|
||||||
|
retry_on_error=True,
|
||||||
|
max_retries=MAX_RETRIES,
|
||||||
|
delay=RETRY_DELAY,
|
||||||
|
)
|
||||||
|
async def score_inbox_message(record_id:str,job_id:str) -> dict:
|
||||||
|
return await score_message_against_job(record_id,job_id)
|
||||||
|
|
||||||
|
|
||||||
@broker.task(
|
@broker.task(
|
||||||
task_name="inbox.match_message",
|
task_name="inbox.match_message",
|
||||||
retry_on_error=True,
|
retry_on_error=True,
|
||||||
|
|
@ -81,6 +136,24 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
||||||
status=status,
|
status=status,
|
||||||
error=result.get("error") or "",
|
error=result.get("error") or "",
|
||||||
)
|
)
|
||||||
|
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
|
||||||
|
# spot — assigned job first, else the agent's top suggestion. Scoring failures
|
||||||
|
# must not fail the match; the match result is already committed above.
|
||||||
|
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
|
||||||
|
score_job_id=None
|
||||||
|
async with session_scope() as session:
|
||||||
|
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
|
||||||
|
if fresh is not None and fresh.assigned_job_post_id:
|
||||||
|
score_job_id=str(fresh.assigned_job_post_id)
|
||||||
|
if score_job_id is None and suggested:
|
||||||
|
score_job_id=suggested[0]
|
||||||
|
if score_job_id:
|
||||||
|
try:
|
||||||
|
outcome=await score_message_against_job(record_id,score_job_id)
|
||||||
|
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status":status,
|
"status":status,
|
||||||
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
|
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,20 @@ class Email:
|
||||||
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
|
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=404,detail="Message not found")
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if job_post_id is not None:
|
||||||
|
# Assignment pairs this CV with a JD we already have — queue the ATS
|
||||||
|
# score in the background so the recruiter is not held on an OpenAI
|
||||||
|
# call. Idempotent server-side; broker-down just logs (the profile's
|
||||||
|
# Score-with-ATS button remains the manual fallback).
|
||||||
|
from inbox.tasks import score_inbox_message
|
||||||
|
try:
|
||||||
|
await score_inbox_message.kicker().with_labels(
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
correlation_id=str(record_id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(record_id),str(job_post_id))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("could not queue ats score for %s: %s",record_id,exc)
|
||||||
return await self.get_inbox_message_by_id(record_id)
|
return await self.get_inbox_message_by_id(record_id)
|
||||||
|
|
||||||
async def mark_read(self,record_id):
|
async def mark_read(self,record_id):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue