Linking user to candidate to job
parent
09fdb39109
commit
08068b2b18
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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))
|
||||
Loading…
Reference in New Issue