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
Talha Ahmed 2026-08-11 20:03:02 +05:00
parent 7c593d32ce
commit da61fe2a3a
2 changed files with 88 additions and 1 deletions

View File

@ -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 [],

View File

@ -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):