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)
if not isinstance(reasoning, str):
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
resume_text: str
experience: str
job_posts: list[dict]
suggested_job_post_ids: list[str]
summary: str

View File

@ -26,7 +26,8 @@ Respond with JSON only:
{
"suggested_job_post_ids": ["uuid", "..."],
"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 [],
"summary": state.get("summary") or "",
"reasoning": state.get("reasoning") or "",
"experience": state.get("experience") or "",
"status": state.get("status") or "failed",
"error": state.get("error") or "",
}

View File

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

View File

@ -15,6 +15,7 @@ router = APIRouter()
async def fetch_email(
top:int=Query(100),
skip:int=Query(0,ge=0),
test_on: bool = Query(True),
token: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
@ -27,13 +28,17 @@ async def fetch_email(
items_lst=[]
for item in value:
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})
if service.pending_match_ids:
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:
raise

View File

@ -1,14 +1,31 @@
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from dotenv import load_dotenv
from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from role.models import EnumRoles, Roles
from sqlalchemy import Column, DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select, true
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):
@ -65,6 +82,7 @@ class Inbox_Messages(SQLModel, table=True):
file_name: str | None = Field(default=None)
file_path: 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))
match_summary: 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 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
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
try:
@ -101,6 +128,7 @@ class Inbox_Messages(SQLModel, table=True):
record_id,
*,
resume_text=None,
experience=None,
suggested_job_post_ids=None,
summary="",
reasoning="",
@ -118,6 +146,7 @@ class Inbox_Messages(SQLModel, table=True):
row.match_reasoning = reasoning or None
row.match_status = status or None
row.match_error = error or None
row.experience = experience or None
row.matched_at = datetime.now(timezone.utc)
session.add(row)
await session.commit()
@ -156,6 +185,72 @@ class Inbox_Messages(SQLModel, table=True):
"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
async def insert_email(
cls,
@ -163,9 +258,11 @@ class Inbox_Messages(SQLModel, table=True):
email_data: dict,
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)
external_id = fields.get("message_id")
link_user=None
if external_id:
existing = (
await session.execute(
@ -178,12 +275,18 @@ class Inbox_Messages(SQLModel, table=True):
session.add(existing)
await session.commit()
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)
session.add(email)
await session.commit()
return email
if fields.get("attachment"):
link_user=await cls._link_sender(session, email_data, email)
return email, link_user
@classmethod
def _search_filter(cls, search: str):

View File

@ -17,10 +17,25 @@ load_dotenv()
EMAIL_URL=os.getenv("EMAIL_URL")
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"
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):
"""GET /sync/read-status -> the raw round dict."""
if not EMAIL_URL:

View File

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

View File

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

View File

@ -9,6 +9,7 @@ from inbox.plugins import (
EMAIL_API_TOKEN,
fetch_message_read_status,
load_message_files,
request_email_confirmation,
)
from dotenv import load_dotenv
load_dotenv()
@ -24,6 +25,7 @@ class Email:
self.get_url=os.getenv("EMAIL_URL")
self.token=token or EMAIL_API_TOKEN
self.pending_match_ids:list[str]=[]
self.pending_confirmation_emails:list[str]=[]
# async def get_all_applications(self,app_id=None):
# try:
@ -49,7 +51,7 @@ class Email:
except Exception as 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:
try:
response=await client.get(f"{self.get_url}/emails/{message_id}",
@ -58,14 +60,14 @@ class Email:
if response.status_code==200:
data=response.json()
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)
if (
insert_func.attachment
and insert_func.file_path
and insert_func.match_status is None
):
self.pending_match_ids.append(str(insert_func.id))
return response.json()
row,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file)
if row.attachment and row.file_path and row.match_status is None:
self.pending_match_ids.append(str(row.id))
if test_on:
return data
if new_user_email:
self.pending_confirmation_emails.append(new_user_email)
return data
else:
raise HTTPException(status_code=response.status_code,detail=response.text)
except Exception as e:
@ -128,6 +130,20 @@ class Email:
task_ids.append(task.task_id)
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):
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)

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
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 users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
@ -101,3 +101,22 @@ async def buffer_channels(
raise
except Exception as 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 pypdf import PdfReader
from sqlalchemy import select
from sqlmodel import true
from inbox.models import Inbox_Messages
from job.candidate.plugins import normalize_spaced_text
@ -66,3 +67,13 @@ class FileRead:
# try:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# 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))