Linking user to candidate to job

Is_READ_UPDATE
ahmed.mujtaba 2026-08-10 16:19:29 +05:00
parent 09fdb39109
commit 08068b2b18
13 changed files with 201 additions and 20 deletions

View File

@ -68,4 +68,9 @@ def parse_match_response(data, allowed_ids) -> tuple[list[str], str, str]:
reasoning = "\n".join(str(item) for item in reasoning) reasoning = "\n".join(str(item) for item in reasoning)
if not isinstance(reasoning, str): if not isinstance(reasoning, str):
reasoning = "" reasoning = ""
return suggested, summary.strip(), reasoning.strip()
experience = data.get("experience")
if not isinstance(experience, str):
experience = ""
return suggested, summary.strip(), reasoning.strip(), experience.strip()

View File

@ -13,6 +13,7 @@ class AgentState(TypedDict, total=False):
subject: str subject: str
resume_text: str resume_text: str
experience: str
job_posts: list[dict] job_posts: list[dict]
suggested_job_post_ids: list[str] suggested_job_post_ids: list[str]
summary: str summary: str

View File

@ -26,7 +26,8 @@ Respond with JSON only:
{ {
"suggested_job_post_ids": ["uuid", "..."], "suggested_job_post_ids": ["uuid", "..."],
"summary": "one short sentence for the recruiter", "summary": "one short sentence for the recruiter",
"reasoning": "brief bullet-style explanation per suggested match" "reasoning": "brief bullet-style explanation per suggested match",
"experience": "the relevant experience of the candidate in years for the suggested match"
} }
""" """

View File

@ -12,6 +12,7 @@ def serialize_agent_result(state: dict) -> dict:
"suggested_job_post_ids": state.get("suggested_job_post_ids") or [], "suggested_job_post_ids": state.get("suggested_job_post_ids") or [],
"summary": state.get("summary") or "", "summary": state.get("summary") or "",
"reasoning": state.get("reasoning") or "", "reasoning": state.get("reasoning") or "",
"experience": state.get("experience") or "",
"status": state.get("status") or "failed", "status": state.get("status") or "failed",
"error": state.get("error") or "", "error": state.get("error") or "",
} }

View File

@ -62,12 +62,13 @@ async def match_jobs(state: AgentState) -> dict:
try: try:
data = await llm_call(prompt(), user_prompt(state), json_mode=True) data = await llm_call(prompt(), user_prompt(state), json_mode=True)
allowed_ids = {item["id"] for item in state.get("job_posts") or []} allowed_ids = {item["id"] for item in state.get("job_posts") or []}
suggested, summary, reasoning = parse_match_response(data, allowed_ids) suggested, summary, reasoning, experience = parse_match_response(data, allowed_ids)
return { return {
"status": "matched", "status": "matched",
"suggested_job_post_ids": suggested, "suggested_job_post_ids": suggested,
"summary": summary, "summary": summary,
"reasoning": reasoning, "reasoning": reasoning,
"experience": experience,
} }
except Exception as exc: except Exception as exc:
logger.exception("agent match_jobs failed") logger.exception("agent match_jobs failed")
@ -77,6 +78,7 @@ async def match_jobs(state: AgentState) -> dict:
"suggested_job_post_ids": [], "suggested_job_post_ids": [],
"summary": "", "summary": "",
"reasoning": "", "reasoning": "",
"experience": "",
} }
@ -86,6 +88,7 @@ async def finalize(state: AgentState) -> dict:
"suggested_job_post_ids": state.get("suggested_job_post_ids") or [], "suggested_job_post_ids": state.get("suggested_job_post_ids") or [],
"summary": state.get("summary") or "", "summary": state.get("summary") or "",
"reasoning": state.get("reasoning") or "", "reasoning": state.get("reasoning") or "",
"experience": state.get("experience") or "",
"status": state.get("status") or "failed", "status": state.get("status") or "failed",
"error": state.get("error") or "", "error": state.get("error") or "",
} }

View File

@ -15,6 +15,7 @@ router = APIRouter()
async def fetch_email( async def fetch_email(
top:int=Query(100), top:int=Query(100),
skip:int=Query(0,ge=0), skip:int=Query(0,ge=0),
test_on: bool = Query(True),
token: str | None = Query(None), token: str | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -27,13 +28,17 @@ async def fetch_email(
items_lst=[] items_lst=[]
for item in value: for item in value:
message_id=item.get("id") message_id=item.get("id")
service_per_email=await service.get_email_by_id(message_id) service_per_email=await service.get_email_by_id(message_id,test_on)
items_lst.append({"message_id":message_id,"email_contents":service_per_email}) items_lst.append({"message_id":message_id,"email_contents":service_per_email})
if service.pending_match_ids: if service.pending_match_ids:
await service.enqueue_matching(list(service.pending_match_ids),force=False) await service.enqueue_matching(list(service.pending_match_ids),force=False)
return JSONResponse(content={"data":items_lst,"status_code":200}) account_setup=[]
if service.pending_confirmation_emails:
account_setup=await service.send_account_setup(list(service.pending_confirmation_emails))
return JSONResponse(content={"data":items_lst,"account_setup":account_setup,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -1,14 +1,31 @@
import logging
import os
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from role.models import EnumRoles, Roles
from sqlalchemy import Column, DateTime, func, or_, update from sqlalchemy import Column, DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select, true from sqlmodel import Field, Relationship, SQLModel, select, true
from users.models import Users from users.models import Users
from users.plugins import hash_password
load_dotenv()
logger = logging.getLogger("inbox.models")
# Placeholder only. The account lands inactive and the candidate is mailed a
# confirmation link; the real password comes from the reset flow afterwards.
DEFAULT_CANDIDATE_PASSWORD = os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")
CANDIDATE_ROLE_ID_FALLBACK = 8 # mirrors users/views.py:signup_user
SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce")
class Inbox(SQLModel, table=True): class Inbox(SQLModel, table=True):
@ -65,6 +82,7 @@ class Inbox_Messages(SQLModel, table=True):
file_name: str | None = Field(default=None) file_name: str | None = Field(default=None)
file_path: str | None = Field(default=None) file_path: str | None = Field(default=None)
resume_text: str | None = Field(default=None) resume_text: str | None = Field(default=None)
experience: str | None = Field(default=None)
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB)) suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
match_summary: str | None = Field(default=None) match_summary: str | None = Field(default=None)
match_reasoning: str | None = Field(default=None) match_reasoning: str | None = Field(default=None)
@ -83,6 +101,15 @@ class Inbox_Messages(SQLModel, table=True):
return body return body
return email_data.get("bodyPreview") or "" return email_data.get("bodyPreview") or ""
# @classmethod
# async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None):
# try:
# qryy=select(cls,Users).join(cls,cls.)
# if user_id
# except Exception as e:
# raise HTTPException(status_code=500,detail=str(e))
@classmethod @classmethod
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None): async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
try: try:
@ -101,6 +128,7 @@ class Inbox_Messages(SQLModel, table=True):
record_id, record_id,
*, *,
resume_text=None, resume_text=None,
experience=None,
suggested_job_post_ids=None, suggested_job_post_ids=None,
summary="", summary="",
reasoning="", reasoning="",
@ -118,6 +146,7 @@ class Inbox_Messages(SQLModel, table=True):
row.match_reasoning = reasoning or None row.match_reasoning = reasoning or None
row.match_status = status or None row.match_status = status or None
row.match_error = error or None row.match_error = error or None
row.experience = experience or None
row.matched_at = datetime.now(timezone.utc) row.matched_at = datetime.now(timezone.utc)
session.add(row) session.add(row)
await session.commit() await session.commit()
@ -156,6 +185,72 @@ class Inbox_Messages(SQLModel, table=True):
"full_email_response": email_data, "full_email_response": email_data,
} }
@classmethod
def _sender_address(cls, email_data: dict) -> str:
return (
email_data.get("from", {})
.get("emailAddress", {})
.get("address", "")
or ""
).strip().lower()
@classmethod
def _sender_display_name(cls, email_data: dict, address: str) -> str:
name = (
email_data.get("from", {})
.get("emailAddress", {})
.get("name")
or ""
).strip()
if name:
return name
return address.split("@", 1)[0] if address else "candidate"
@classmethod
def _is_linkable_sender(cls, address: str) -> bool:
if not address or "@" not in address:
return False
local = address.split("@", 1)[0]
return not local.startswith(SKIP_SENDER_PREFIXES)
@classmethod
async def _link_sender(cls,session:AsyncSession,email_data:dict,email):
address=cls._sender_address(email_data)
if not cls._is_linkable_sender(address):
return None
try:
user=(await session.execute(
select(Users).where(func.lower(Users.email)==address)
)).scalars().first()
if not user:
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
user=Users(
name=cls._sender_display_name(email_data,address),
email=address,
role_id=role.id if role else CANDIDATE_ROLE_ID_FALLBACK,
password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
)
session.add(user)
session.add(Inbox(user_id=user.id,message_id=email.id))
await session.commit()
return address
link=(await session.execute(
select(Inbox).where(Inbox.message_id==email.id,Inbox.user_id==user.id)
)).scalars().first()
if not link:
session.add(Inbox(user_id=user.id,message_id=email.id))
await session.commit()
return None
except IntegrityError:
await session.rollback()
return None
except Exception as e:
await session.rollback()
logger.warning("sender link failed for %s: %s",address,e)
return None
@classmethod @classmethod
async def insert_email( async def insert_email(
cls, cls,
@ -163,9 +258,11 @@ class Inbox_Messages(SQLModel, table=True):
email_data: dict, email_data: dict,
file_path: list[str] | None = None, file_path: list[str] | None = None,
): ):
"""Returns (row, new_user_email). new_user_email is set only when this call
created the sender's Users row."""
fields = cls._fields_from_email(email_data, file_path) fields = cls._fields_from_email(email_data, file_path)
external_id = fields.get("message_id") external_id = fields.get("message_id")
link_user=None
if external_id: if external_id:
existing = ( existing = (
await session.execute( await session.execute(
@ -178,12 +275,18 @@ class Inbox_Messages(SQLModel, table=True):
session.add(existing) session.add(existing)
await session.commit() await session.commit()
await session.refresh(existing) await session.refresh(existing)
return existing
if fields.get("attachment"):
link_user=await cls._link_sender(session, email_data, existing)
return existing, link_user
email = cls(**fields) email = cls(**fields)
session.add(email) session.add(email)
await session.commit() await session.commit()
return email
if fields.get("attachment"):
link_user=await cls._link_sender(session, email_data, email)
return email, link_user
@classmethod @classmethod
def _search_filter(cls, search: str): def _search_filter(cls, search: str):

View File

@ -17,10 +17,25 @@ load_dotenv()
EMAIL_URL=os.getenv("EMAIL_URL") EMAIL_URL=os.getenv("EMAIL_URL")
EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN") EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
BACKEND_URL=os.getenv("BACKEND_URL","http://localhost:8000")
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments" _ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
async def request_email_confirmation(email):
"""POST /users/confirm-email/resend on this same service -> status code.
Goes through the endpoint rather than importing Confirmation so the token row,
resend cooldown and mail send stay on one code path.
"""
async with httpx.AsyncClient(timeout=20.0) as client:
response=await client.post(
f"{BACKEND_URL.rstrip('/')}/users/confirm-email/resend",
json={"email":email},
)
return response.status_code
async def fetch_read_status_delta(folder, since=None, limit=1000, max_pages=10, token=None): async def fetch_read_status_delta(folder, since=None, limit=1000, max_pages=10, token=None):
"""GET /sync/read-status -> the raw round dict.""" """GET /sync/read-status -> the raw round dict."""
if not EMAIL_URL: if not EMAIL_URL:

View File

@ -97,7 +97,7 @@ def serialize_application(message: Inbox_Messages) -> dict:
"resume_text": message.resume_text, "resume_text": message.resume_text,
"ats_score": None, "ats_score": None,
"phone": None, "phone": None,
"experience": None, "experience": message.experience or "",
"recruiter": None, "recruiter": None,
"duplicate": None, "duplicate": None,
} }

View File

@ -80,12 +80,13 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
if status=="failed": if status=="failed":
raise RuntimeError(error or "agent returned failed status") raise RuntimeError(error or "agent returned failed status")
logger.info("agent result: %s,%s",result,result.get("experience"))
async with session_scope() as session: async with session_scope() as session:
await Inbox_Messages.set_match_result( await Inbox_Messages.set_match_result(
session, session,
record_id, record_id,
resume_text=text, resume_text=text,
experience=result.get("experience") or "",
suggested_job_post_ids=result.get("suggested_job_post_ids") or [], suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
summary=result.get("summary") or "", summary=result.get("summary") or "",
reasoning=result.get("reasoning") or "", reasoning=result.get("reasoning") or "",

View File

@ -9,6 +9,7 @@ from inbox.plugins import (
EMAIL_API_TOKEN, EMAIL_API_TOKEN,
fetch_message_read_status, fetch_message_read_status,
load_message_files, load_message_files,
request_email_confirmation,
) )
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@ -24,6 +25,7 @@ class Email:
self.get_url=os.getenv("EMAIL_URL") self.get_url=os.getenv("EMAIL_URL")
self.token=token or EMAIL_API_TOKEN self.token=token or EMAIL_API_TOKEN
self.pending_match_ids:list[str]=[] self.pending_match_ids:list[str]=[]
self.pending_confirmation_emails:list[str]=[]
# async def get_all_applications(self,app_id=None): # async def get_all_applications(self,app_id=None):
# try: # try:
@ -49,7 +51,7 @@ class Email:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
async def get_email_by_id(self,message_id): async def get_email_by_id(self,message_id,test_on=True):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
response=await client.get(f"{self.get_url}/emails/{message_id}", response=await client.get(f"{self.get_url}/emails/{message_id}",
@ -58,14 +60,14 @@ class Email:
if response.status_code==200: if response.status_code==200:
data=response.json() data=response.json()
re_create_file=await decode_attachment(data.get("attachments")) re_create_file=await decode_attachment(data.get("attachments"))
insert_func=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file) row,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file)
if ( if row.attachment and row.file_path and row.match_status is None:
insert_func.attachment self.pending_match_ids.append(str(row.id))
and insert_func.file_path if test_on:
and insert_func.match_status is None return data
): if new_user_email:
self.pending_match_ids.append(str(insert_func.id)) self.pending_confirmation_emails.append(new_user_email)
return response.json() return data
else: else:
raise HTTPException(status_code=response.status_code,detail=response.text) raise HTTPException(status_code=response.status_code,detail=response.text)
except Exception as e: except Exception as e:
@ -128,6 +130,20 @@ class Email:
task_ids.append(task.task_id) task_ids.append(task.task_id)
return task_ids return task_ids
async def send_account_setup(self,emails):
"""Mail a confirmation link per freshly created sender. Best effort: a failed
mail must not fail a fetch whose messages are already stored."""
results=[]
for email in emails or []:
try:
status=await request_email_confirmation(email)
except Exception as exc:
logger.warning("confirmation request failed for %s: %s",email,exc)
results.append({"email":email,"sent":False})
continue
results.append({"email":email,"sent":status==200})
return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from db_setup import get_session from db_setup import get_session
from job.candidate.views import FileRead from job.candidate.views import FileRead,CandidateView
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate from job.job_post.views import JobPost,JobPostCreate
@ -101,3 +101,22 @@ async def buffer_channels(
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch")
async def fetch_candidate(
user_id:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
if user_id:
data=await service.get_candidate(user_id=user_id)
else:
data=await service.get_candidate()
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -4,6 +4,7 @@ from datetime import datetime,timezone
from fastapi import HTTPException from fastapi import HTTPException
from pypdf import PdfReader from pypdf import PdfReader
from sqlalchemy import select from sqlalchemy import select
from sqlmodel import true
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from job.candidate.plugins import normalize_spaced_text from job.candidate.plugins import normalize_spaced_text
@ -66,3 +67,13 @@ class FileRead:
# try: # try:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id) # get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# get_file= # get_file=
class CandidateView:
def __init__(self,session:AsyncSession):
self.session=session
async def get_candidate(self,user_id=None):
try:
call_func=Inbox_Messages.get_candidate_profile(user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))