552 lines
23 KiB
Python
552 lines
23 KiB
Python
from inbox.models import Inbox
|
|
from typing import Any,List,Dict
|
|
|
|
from job.candidate.plugins import documents_from_message, source_from_message_to
|
|
|
|
|
|
def _first_file_path(value):
|
|
if not value:
|
|
return None
|
|
return str(value).split(",")[0].strip() or None
|
|
from job.interviews.serializers import serialize_interview
|
|
from job.activity.serializers import serialize_activity
|
|
from job.feedback.serializers import serialize_feedback
|
|
from job.job_post.serializers import serialize_job_post
|
|
|
|
def _id_str(value):
|
|
if value in (None,""):
|
|
return None
|
|
return str(value)
|
|
|
|
def _id_list(value):
|
|
if not value:
|
|
return []
|
|
if isinstance(value,(list,tuple)):
|
|
return [str(v) for v in value if v not in (None,"")]
|
|
return [str(value)]
|
|
|
|
def serialize_candidate(row) -> dict:
|
|
return {
|
|
"id": str(row.id),
|
|
"job_id": str(row.job_id),
|
|
"source": row.source,
|
|
"filename": row.filename,
|
|
"file_path": row.file_path,
|
|
"content_sha256": row.content_sha256,
|
|
"candidate_email": row.candidate_email,
|
|
"candidate_name": row.candidate_name,
|
|
"job_title": row.job_title,
|
|
"current_company": row.current_company,
|
|
"years_experience": row.years_experience,
|
|
"match_score": row.match_score,
|
|
"matched_keywords": list(row.matched_keywords or []),
|
|
"missing_keywords": list(row.missing_keywords or []),
|
|
"summary_critique": row.summary_critique,
|
|
"professional_summary": row.professional_summary or None,
|
|
"linkedin_url": row.linkedin_url or None,
|
|
"inbox_message_id": str(row.inbox_message_id) if getattr(row, "inbox_message_id", None) else None,
|
|
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if getattr(row, "manual_upload_candidate_id", None) else None,
|
|
"status": row.status,
|
|
"error_code": row.error_code,
|
|
"error_message": row.error_message,
|
|
"model": row.model,
|
|
"created_by": str(row.created_by),
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
|
|
"""CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id."""
|
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
|
job_payload=serialize_job_post(job_post) if job_post else None
|
|
return {
|
|
"id":str(row.id),
|
|
"name":name,
|
|
"email":(row.candidate_email or "").strip() or None,
|
|
"file_name":(row.file_name or "").strip() or None,
|
|
"file_path":(row.file_path or "").strip() or None,
|
|
"resume_text":row.full_text or None,
|
|
"linkedin_url":row.linkedin_url or None,
|
|
"apply_via":row.apply_via,
|
|
"status":row.status or None,
|
|
"user_id":str(row.user_id) if row.user_id else None,
|
|
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
|
"assigned_job_post":job_payload,
|
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
|
"""A CV held with no job, for the CV Bank screen.
|
|
|
|
Same shape as serialize_bank_silver_medalist so the table renders one row
|
|
type regardless of which population the candidate came from. `id` is
|
|
prefixed because the two sources have different key spaces and would
|
|
otherwise collide in a merged list.
|
|
|
|
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
|
|
by the CV Bank table. ai_score is filled after serialize by joining the
|
|
latest candidates row for this email — this function leaves it None.
|
|
Speculative uploads have no inbox suggestions; suggested_job_post_ids is [].
|
|
"""
|
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
|
assigned=_id_str(row.job_post_id)
|
|
return {
|
|
"id":f"bank:{row.id}",
|
|
"record_id":str(row.id),
|
|
"bank_source":"speculative",
|
|
"name":name,
|
|
"email":(row.candidate_email or "").strip() or None,
|
|
"phone":(row.candidate_phone or "").strip() or None,
|
|
"file_name":(row.file_name or "").strip() or None,
|
|
"file_path":(row.file_path or "").strip() or None,
|
|
"linkedin_url":row.linkedin_url or None,
|
|
"current_company":(row.current_company or "").strip() or None,
|
|
"current_position":(row.current_position or "").strip() or None,
|
|
"education":(row.education or "").strip() or None,
|
|
"city":(getattr(row,"city",None) or "").strip() or None,
|
|
"skills":list(row.skills or []),
|
|
"years_experience":row.years_experience,
|
|
"ai_score":None,
|
|
"recommendation":None,
|
|
"rank_score":rank_score,
|
|
"last_job_title":None,
|
|
"bank_reason":(row.bank_reason or "").strip() or None,
|
|
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
|
"user_id":str(row.user_id) if row.user_id else None,
|
|
"message_id":None,
|
|
"assigned_job_post_id":assigned,
|
|
"assigned_job_title":None,
|
|
"scored_job_post_id":None,
|
|
"scored_job_title":None,
|
|
"suggested_job_post_ids":[],
|
|
"suggested_jobs":[],
|
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
|
"""A past applicant who scored well and did not get the job.
|
|
|
|
Read from the live application tables rather than copied into the bank, so
|
|
there is no second source of truth to keep in sync. `row` is the flat
|
|
mapping produced by Inbox.list_silver_medalists.
|
|
"""
|
|
def get(key):
|
|
value=row.get(key)
|
|
return value.strip() if isinstance(value,str) else value
|
|
|
|
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
|
expires=get("bank_expires_at")
|
|
created=get("created_at")
|
|
assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id"))
|
|
suggested=_id_list(row.get("suggested_job_post_ids"))
|
|
last_title=get("last_job_title") or None
|
|
return {
|
|
"id":f"app:{get('inbox_id')}",
|
|
"record_id":str(get("inbox_id")),
|
|
"bank_source":"silver_medalist",
|
|
"name":name,
|
|
"email":get("email") or None,
|
|
"phone":get("phone") or None,
|
|
"file_name":get("file_name") or None,
|
|
"file_path":get("file_path") or None,
|
|
"linkedin_url":get("linkedin_url") or None,
|
|
"current_company":get("current_company") or None,
|
|
"current_position":get("current_title") or None,
|
|
"education":get("education") or None,
|
|
"city":get("city") or None,
|
|
# Inbox applications never ran the skills extraction — their structured
|
|
# signal is the ATS score, which is stronger than a keyword list.
|
|
"skills":list(row.get("matched_keywords") or []),
|
|
"years_experience":get("years_experience"),
|
|
"ai_score":get("ai_score"),
|
|
"recommendation":get("recommendation"),
|
|
"rank_score":rank_score,
|
|
"last_job_title":last_title,
|
|
"bank_reason":"silver_medalist",
|
|
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
|
"user_id":str(get("user_id")) if get("user_id") else None,
|
|
"message_id":_id_str(get("message_id")),
|
|
"assigned_job_post_id":assigned,
|
|
"assigned_job_title":last_title,
|
|
"scored_job_post_id":assigned,
|
|
"scored_job_title":last_title,
|
|
"suggested_job_post_ids":suggested,
|
|
"suggested_jobs":[],
|
|
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
|
"updated_at":None,
|
|
}
|
|
|
|
|
|
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
|
|
return {
|
|
"id":str(row.id) if row.id else None,
|
|
"candidate_email":row.candidate_email,
|
|
"candidate_name":row.candidate_name,
|
|
"candidate_phone":row.candidate_phone,
|
|
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
|
"full_text":row.full_text,
|
|
"linkedin_url":row.linkedin_url or None,
|
|
"current_company":row.current_company,
|
|
"current_position":row.current_position,
|
|
"apply_via":row.apply_via,
|
|
"user_id":str(row.user_id) if row.user_id else None,
|
|
"platform":row.platform,
|
|
"created_by":str(row.created_by) if row.created_by else None,
|
|
"experience":row.experience,
|
|
"status":row.status,
|
|
"referral_by":row.referral_by,
|
|
"file_name":row.file_name,
|
|
"file_path":row.file_path,
|
|
"favorite":row.favorite,
|
|
"rating":row.rating,
|
|
"created_at":row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def serialize_candidate_profile(
|
|
link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]],
|
|
*,
|
|
detail:bool=False,
|
|
) -> Dict[str,Any]|List[Dict[str,Any]]:
|
|
if isinstance(link,list):
|
|
return [serialize_candidate_profile(item,detail=detail) for item in link]
|
|
if isinstance(link,dict):
|
|
return link
|
|
|
|
user = link.user
|
|
message = link.messages
|
|
payload = {
|
|
"inbox_id": link.id,
|
|
"manual_upload_candidate_id": None,
|
|
"user_id": str(link.user_id) if link.user_id else None,
|
|
"candidate_id": None,
|
|
"name": user.name if user else None,
|
|
"email": user.email if user else None,
|
|
"linkedin_url": (user.linkedin_url if user else None) or None,
|
|
"is_active": user.is_active if user else None,
|
|
"message_id": str(link.message_id) if link.message_id else None,
|
|
"created_at": link.created_at.isoformat() if link.created_at else None,
|
|
"application_status": message.application_status if message else None,
|
|
"experience": message.experience if message else None,
|
|
"current_employment": message.current_employment if message else None,
|
|
"current_title": message.current_title if message else None,
|
|
"resume_text": message.resume_text if message else None,
|
|
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
|
|
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
|
|
"match_summary": message.match_summary if message else None,
|
|
"match_reasoning": message.match_reasoning if message else None,
|
|
"match_status": message.match_status if message else None,
|
|
"match_error": message.match_error if message else None,
|
|
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
|
|
"professional_summary": (
|
|
(message.professional_summary if message else None)
|
|
or (user.professional_summary if user else None)
|
|
),
|
|
"file_path": _first_file_path(message.file_path if message else None),
|
|
"job_posts": [],
|
|
}
|
|
if not detail:
|
|
return payload
|
|
|
|
payload.update({
|
|
"favorite": link.favorite,
|
|
"rating": link.rating,
|
|
"phone": message.candidate_phone_number if message else None,
|
|
"education": message.candidate_education if message else None,
|
|
"currentCompany": message.current_employment if message else None,
|
|
"current_title": message.current_title if message else None,
|
|
"stage": message.application_status if message else None,
|
|
"source": source_from_message_to(message.message_to if message else None),
|
|
"applied": message.message_received_time if message else None,
|
|
"documents": documents_from_message(
|
|
message.file_name if message else None,
|
|
message.file_path if message else None,
|
|
),
|
|
"recruiter": None,
|
|
"recruiter_id": None,
|
|
"job_title": None,
|
|
"ai_score": None,
|
|
"recommendation": None,
|
|
"sub_scores": None,
|
|
"interviews": [serialize_interview(r) for r in (link.interviews or [])],
|
|
"activity": [serialize_activity(r) for r in (link.activity or [])],
|
|
"feedback": [serialize_feedback(r) for r in (link.feedback or [])],
|
|
"notes": [],
|
|
})
|
|
return payload
|
|
|
|
|
|
def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|
"""Same key vocabulary as serialize_candidate_profile(detail=True).
|
|
|
|
Manual uploads never go through inbox, so current_position maps onto
|
|
current_title here and the agent/ATS fields stay empty.
|
|
"""
|
|
job_payload = serialize_job_post(job_post) if job_post else None
|
|
created = row.created_at.isoformat() if row.created_at else None
|
|
file_name = (row.file_name or "").strip() or None
|
|
file_path = (row.file_path or "").strip() or None
|
|
documents = [{"name": file_name or "", "path": file_path or ""}] if (file_name or file_path) else []
|
|
company = (row.current_company or "").strip() or None
|
|
position = (row.current_position or "").strip() or None
|
|
return {
|
|
"inbox_id": None,
|
|
"manual_upload_candidate_id": str(row.id),
|
|
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
|
"candidate_id": None,
|
|
"name": (user.name if user else None) or row.candidate_name or None,
|
|
"email": (user.email if user else None) or row.candidate_email or None,
|
|
"linkedin_url": (user.linkedin_url if user else None) or row.linkedin_url or None,
|
|
"is_active": user.is_active if user else None,
|
|
"message_id": None,
|
|
"created_at": created,
|
|
"application_status": row.status or None,
|
|
"experience": (row.experience or "").strip() or (str(row.years_experience) if row.years_experience is not None else None),
|
|
"current_employment": company,
|
|
"current_title": position,
|
|
"resume_text": row.full_text or None,
|
|
"suggested_job_post_ids": [],
|
|
"assigned_job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
|
"match_summary": None,
|
|
"match_reasoning": None,
|
|
"match_status": None,
|
|
"match_error": None,
|
|
"matched_at": None,
|
|
"professional_summary": row.professional_summary or (user.professional_summary if user else None),
|
|
"job_posts": [job_payload] if job_payload else [],
|
|
"favorite": None,
|
|
"rating": None,
|
|
"phone": (row.candidate_phone or "").strip() or None,
|
|
"education": (row.education or "").strip() or None,
|
|
"currentCompany": company,
|
|
"stage": row.status or None,
|
|
"source": (row.platform or "").strip() or None,
|
|
"applied": created,
|
|
"file_path": file_path,
|
|
"documents": documents,
|
|
"recruiter": job_payload.get("created_by_name") if job_payload else None,
|
|
"recruiter_id": job_payload.get("created_by") if job_payload else None,
|
|
"job_title": job_payload.get("title") if job_payload else None,
|
|
"ai_score": None,
|
|
"recommendation": None,
|
|
"sub_scores": None,
|
|
"interviews": [],
|
|
"activity": [],
|
|
"feedback": [],
|
|
"notes": [],
|
|
"assigned_job_post": job_payload,
|
|
"matched_keywords": [],
|
|
"missing_keywords": [],
|
|
"summary_critique": None,
|
|
"scored_at": None,
|
|
}
|
|
|
|
|
|
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
|
|
return {
|
|
"inbox_id": None,
|
|
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
|
|
"user_id": profile.get("user_id"),
|
|
"candidate_id": None,
|
|
"name": profile.get("name"),
|
|
"email": profile.get("email"),
|
|
"is_active": profile.get("is_active"),
|
|
"message_id": None,
|
|
"created_at": profile.get("created_at"),
|
|
"application_status": profile.get("application_status"),
|
|
"experience": profile.get("experience"),
|
|
"current_employment": profile.get("current_employment"),
|
|
"current_title": profile.get("current_title"),
|
|
"resume_text": None,
|
|
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
|
|
"assigned_job_post_id": profile.get("assigned_job_post_id"),
|
|
"job_posts": profile.get("job_posts") or [],
|
|
"assigned_job_post": profile.get("assigned_job_post"),
|
|
"job_title": profile.get("job_title"),
|
|
"recruiter": profile.get("recruiter"),
|
|
"recruiter_id": profile.get("recruiter_id"),
|
|
"source": profile.get("source"),
|
|
"file_path": profile.get("file_path"),
|
|
"ai_score": None,
|
|
"recommendation": None,
|
|
}
|
|
|
|
|
|
def serialize_form_candidate_list(row) -> Dict[str, Any]:
|
|
"""GET /candidate/fetch list row for an unpromoted FormData application.
|
|
|
|
processing_state is an inbox tab, not Candidate_application_Status, so it
|
|
is not copied onto application_status — CLOSED/rejected-tab values would
|
|
paint every sheet row as Rejected on Candidates.
|
|
"""
|
|
assigned = row.assigned_job_post_id or row.job_post_id
|
|
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
|
|
created = row.created_at.isoformat() if row.created_at else None
|
|
name = (row.name or "").strip() or None
|
|
email = (row.candidate_email or "").strip() or None
|
|
return {
|
|
"inbox_id": None,
|
|
"form_data_id": str(row.id),
|
|
"manual_upload_candidate_id": None,
|
|
"user_id": None,
|
|
"candidate_id": None,
|
|
"name": name,
|
|
"email": email,
|
|
"is_active": None,
|
|
"message_id": None,
|
|
"created_at": created,
|
|
"application_status": None,
|
|
"experience": (row.experience or "").strip() or None,
|
|
"current_employment": (row.current_company or "").strip() or None,
|
|
"current_title": (row.position_applied_for or "").strip() or None,
|
|
"resume_text": None,
|
|
"suggested_job_post_ids": suggested,
|
|
"assigned_job_post_id": str(assigned) if assigned else None,
|
|
"job_posts": [],
|
|
"assigned_job_post": None,
|
|
"job_title": (row.position_applied_for or "").strip() or None,
|
|
"recruiter": None,
|
|
"recruiter_id": None,
|
|
"source": "Form",
|
|
"file_path": None,
|
|
"ai_score": None,
|
|
"recommendation": None,
|
|
}
|
|
|
|
|
|
def serialize_manager_candidate(row, *, source) -> dict:
|
|
"""One application on a hiring-manager's job — list row, not the profile."""
|
|
inbox_id = row.get("inbox_id")
|
|
manual_id = row.get("id") if source == "manual" else None
|
|
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
|
|
user_id = row.get("user_id")
|
|
ats = row.get("ats_result") or {}
|
|
score = ats.get("overall_score")
|
|
band = (ats.get("band") or "").strip() or None
|
|
if score is not None and not band:
|
|
band = "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
|
|
return {
|
|
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
|
|
"user_id": user_id,
|
|
"name": row.get("name"),
|
|
"email": row.get("email") or row.get("candidate_email"),
|
|
"job_post_id": job_post_id,
|
|
"job_title": row.get("title"),
|
|
"application_status": row.get("application_status"),
|
|
"inbox_id": inbox_id,
|
|
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
|
|
"created_at": row.get("created_at"),
|
|
"source": source,
|
|
"ai_score": score,
|
|
"recommendation": band,
|
|
}
|
|
|
|
|
|
_WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
|
|
|
|
|
|
def is_assigned_application(row) -> bool:
|
|
"""True when the row is an application to a real job, not an unassigned email.
|
|
|
|
Sheet forms name a role in job_title even before a job post is linked.
|
|
Unassigned inbox mail is still a kept attempt — see is_kept_application.
|
|
"""
|
|
if not isinstance(row, dict):
|
|
return False
|
|
if row.get("job_post_id"):
|
|
return True
|
|
if row.get("source") == "form" and row.get("job_title"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_kept_application(row) -> bool:
|
|
"""True when the row is a real application, including unassigned inbox mail.
|
|
|
|
A CV attachment counts even when text extraction failed (`no_text`) — they
|
|
still applied. Body-only mail and classifier drops stay in history but do
|
|
not count as a reapplication. Two On-Hold emails from the same person do.
|
|
"""
|
|
if not isinstance(row, dict):
|
|
return False
|
|
if row.get("source") == "filtered":
|
|
return False
|
|
if row.get("source") == "inbox" and row.get("attachment") is False:
|
|
return False
|
|
if row.get("source") == "inbox" and row.get("attachment") is True:
|
|
return True
|
|
return rejection_reason(row) != "wrong_format"
|
|
|
|
|
|
def rejection_reason(row) -> str | None:
|
|
"""Why an unassigned attempt never reached a job — or None if it is still open.
|
|
|
|
Wrong format: no CV, unreadable PDF, matcher failed, or the classifier
|
|
kept the mail out of the inbox. Assigned rows keep their pipeline status.
|
|
"""
|
|
if not isinstance(row, dict) or is_assigned_application(row):
|
|
return None
|
|
if row.get("rejection_reason") == "wrong_format":
|
|
return "wrong_format"
|
|
if row.get("source") == "filtered":
|
|
return "wrong_format"
|
|
match = str(row.get("match_status") or "").strip().lower()
|
|
if match in _WRONG_FORMAT_MATCH:
|
|
return "wrong_format"
|
|
if row.get("source") == "inbox" and row.get("attachment") is False:
|
|
return "wrong_format"
|
|
return None
|
|
|
|
|
|
def serialize_application_history_item(row) -> dict:
|
|
"""One prior application / score / sheet row for a reapplicant lookup."""
|
|
reason = rejection_reason(row)
|
|
status = row.get("status")
|
|
if reason == "wrong_format":
|
|
status = "WRONG_FORMAT"
|
|
return {
|
|
"source": row.get("source"),
|
|
"inbox_id": row.get("inbox_id"),
|
|
"message_id": row.get("message_id"),
|
|
"upstream_id": row.get("upstream_id"),
|
|
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
|
"form_data_id": row.get("form_data_id"),
|
|
"candidate_id": row.get("candidate_id"),
|
|
"user_id": str(row.get("user_id")) if row.get("user_id") else None,
|
|
"job_post_id": row.get("job_post_id"),
|
|
"job_title": row.get("job_title"),
|
|
"status": status,
|
|
"applied_at": row.get("applied_at"),
|
|
"rejection_reason": reason,
|
|
"match_status": row.get("match_status"),
|
|
"attachment": row.get("attachment"),
|
|
}
|
|
|
|
|
|
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
|
|
items = [
|
|
item for item in (
|
|
serialize_application_history_item(row) for row in (applications or [])
|
|
)
|
|
if is_kept_application(item)
|
|
]
|
|
found = bool(user or present_in or items)
|
|
return {
|
|
"email": email,
|
|
"found": found,
|
|
"present_in": list(present_in or []),
|
|
"user": (
|
|
{"id": str(user.id), "name": user.name, "email": user.email}
|
|
if user is not None else None
|
|
),
|
|
"is_reapplicant": len(items) > 1,
|
|
"applications": items,
|
|
}
|