From e717dbf704ad6869fa94e1e0f89a10de26a72d96 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 16:42:55 +0500 Subject: [PATCH] . --- backend/employment_agent/decorators.py | 75 +++++++++++++++++++++++ backend/employment_agent/execute_agent.py | 27 ++++++++ backend/employment_agent/prompt.py | 37 +++++++++++ backend/inbox/models.py | 46 +++++++++++++- backend/inbox/plugins.py | 13 ++++ backend/inbox/serializers.py | 11 ++-- backend/inbox/tasks.py | 16 ++++- backend/job/candidate/models.py | 71 +++++++++++++++++++-- backend/job/candidate/serializers.py | 1 + backend/users/models.py | 17 +++-- 10 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 backend/employment_agent/decorators.py create mode 100644 backend/employment_agent/execute_agent.py create mode 100644 backend/employment_agent/prompt.py diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py new file mode 100644 index 0000000..b2bf69e --- /dev/null +++ b/backend/employment_agent/decorators.py @@ -0,0 +1,75 @@ +"""Employment response decorators for `parse_employment_response`. + +Pure module: no FastAPI imports, no HTTPException, and no module-level state. +Mirrors job/candidate/decorators.py — stacked wrappers that clean LLM output +before the task persists it: + + raw JSON -> require_json_object -> clamp_company_to_resume + -> clamp_education_to_resume -> parse_employment_response +""" + +from __future__ import annotations + +from functools import wraps + +from employment_agent.prompt import EDUCATION,NO_COMPANY + + +def require_json_object(func): + """Reject non-dict LLM payloads before field parsing runs.""" + + @wraps(func) + def wrapper(data,resume_text="",*args,**kwargs): + if not isinstance(data,dict): + raise RuntimeError(f"model did not return a JSON object: {data!r}") + return func(data,resume_text,*args,**kwargs) + + return wrapper + + +def clamp_company_to_resume(func): + """Keep company only when it appears in resume_text; else NO_COMPANY.""" + + @wraps(func) + def wrapper(data,resume_text="",*args,**kwargs): + company,education=func(data,resume_text,*args,**kwargs) + company=(company or "").strip() + if not company or company.lower()==NO_COMPANY.lower(): + return NO_COMPANY,education + haystack=(resume_text or "").lower() + if company.lower() not in haystack: + return NO_COMPANY,education + return company,education + + return wrapper + + +def clamp_education_to_resume(func): + """Keep education only when it appears in resume_text; else EDUCATION.""" + + @wraps(func) + def wrapper(data,resume_text="",*args,**kwargs): + company,education=func(data,resume_text,*args,**kwargs) + education=(education or "").strip() + if not education or education.lower()==EDUCATION.lower(): + return company,EDUCATION + haystack=(resume_text or "").lower() + if education.lower() not in haystack: + return company,EDUCATION + return company,education + + return wrapper + + +@require_json_object +@clamp_company_to_resume +@clamp_education_to_resume +def parse_employment_response(data,resume_text:str="") -> tuple[str,str]: + """Pull company + education from LLM JSON; decorators clamp to the resume.""" + current=data.get("current_employment") + education=data.get("education") + if not isinstance(current,str): + current="" + if not isinstance(education,str): + education="" + return current.strip(),education.strip() diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py new file mode 100644 index 0000000..7f74380 --- /dev/null +++ b/backend/employment_agent/execute_agent.py @@ -0,0 +1,27 @@ +"""Employment extraction entrypoint — llm_setup.llm_call only. + +Pure module: no FastAPI imports and no HTTPException. +Called from inbox.tasks.match_inbox_message; no HTTP surface. +""" + +from __future__ import annotations + +import logging + +from employment_agent.decorators import parse_employment_response +from employment_agent.prompt import EDUCATION,NO_COMPANY,prompt,user_prompt +from llm_setup import llm_call + +logger=logging.getLogger("employment_agent") + + +async def run_employment_agent(*,resume_text="") -> tuple[str,str]: + text=(resume_text or "").strip() + if not text: + return NO_COMPANY,EDUCATION + try: + data=await llm_call(prompt(),user_prompt(text),json_mode=True) + return parse_employment_response(data,text) + except Exception as e: + logger.exception("employment llm_call failed") + raise RuntimeError(str(e)) from e diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py new file mode 100644 index 0000000..d3175b5 --- /dev/null +++ b/backend/employment_agent/prompt.py @@ -0,0 +1,37 @@ +"""Employment LLM prompt builders. + +Pure module: no FastAPI imports and no HTTPException. +""" + +from __future__ import annotations + +import json + +NO_COMPANY="no company was mentioned" +EDUCATION="No Education Mentioned" + + +def prompt(): + return f"""You are an HR-ATS recruiting assistant. + +You are given CV/resume text. Identify the candidate's CURRENT employer company +name and their education (degree / school) when present. + +Rules: +- Return only the company name that appears in the resume text for the ongoing / most recent role. +- Return only education that appears in the resume text. +- The company string you return MUST appear verbatim (or as a clear substring) in the resume text. +- The education string you return MUST appear verbatim (or as a clear substring) in the resume text. +- Do not invent a company. If none is mentioned, return exactly: {NO_COMPANY} +- Do not invent education. If none is mentioned, return exactly: {EDUCATION} + +Respond with JSON only: +{{ + "current_employment": "Company Name", + "education": "Degree / School" +}} +""" + + +def user_prompt(resume_text:str) -> str: + return json.dumps({"resume_text":resume_text or ""},ensure_ascii=False) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index d1e711c..492becb 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,3 +1,4 @@ +from ast import List import logging import os import uuid @@ -15,7 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select, true -from users.models import Users +from job.candidate.models import Interviews +from users.models import Users, Activity, Notes from users.plugins import hash_password load_dotenv() @@ -43,8 +45,35 @@ class Inbox(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) + + favorite:Optional[bool] = Field(default=False) + rating:Optional[float] = Field(default=0.0) + notes_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="notes.id", + ) + notes: List[Notes] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"} + ) - user: Optional[Users] = Relationship( + interview_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="interviews.id", + ) + interview: List[Interviews] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"} + ) + + activity_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="activity.id" + ) + activity: Optional[Activity] = Relationship( + back_populates="inbox", + ) + Optional[Users] = Relationship( back_populates="inbox", sa_relationship_kwargs={"lazy": "joined"}, ) @@ -140,7 +169,9 @@ class Inbox_Messages(SQLModel, table=True): match_status: str | None = Field(default=None) match_error: str | None = Field(default=None) matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) - + candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx") + candidate_education: str | None = Field(default=None) + current_employment: str | None = Field(default=None) inbox: list[Inbox] = Relationship(back_populates="messages") @staticmethod @@ -172,6 +203,9 @@ class Inbox_Messages(SQLModel, table=True): *, resume_text=None, experience=None, + candidate_education=None, + candidate_phone_number=None, + current_employment=None, suggested_job_post_ids=None, summary="", reasoning="", @@ -184,6 +218,12 @@ class Inbox_Messages(SQLModel, table=True): return None if resume_text is not None: row.resume_text = resume_text + if candidate_phone_number is not None: + row.candidate_phone_number = candidate_phone_number + if candidate_education is not None: + row.candidate_education = candidate_education + if current_employment is not None: + row.current_employment = current_employment row.suggested_job_post_ids = suggested_job_post_ids row.match_summary = summary or None row.match_reasoning = reasoning or None diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 9548c6d..7af0ec8 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import os +import re from pathlib import Path from urllib.parse import quote @@ -20,6 +21,11 @@ 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" +# Prefer +92 / 03xx style numbers; fall back to a looser intl-ish pattern. +_PHONE=re.compile( + r"(?:\+?92[\s\-]?)?0?3\d{2}[\s\-]?\d{7}" + r"|(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-]?)?\d{3,4}[\s\-]?\d{3,4}" +) async def request_email_confirmation(email): @@ -127,6 +133,13 @@ def load_message_files(message:Inbox_Messages) -> list[dict]: return files +def extract_phone(text:str) -> str|None: + m=_PHONE.search(text or "") + if not m: + return None + return re.sub(r"[\s\-()]+"," ",m.group(0)).strip() + + async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]: candidates=[resolve_attachment_path(p) for p in (file_paths or []) if p and p.strip()] existing=[p for p in candidates if p.is_file() and p.suffix.lower()==".pdf"] diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 4419fd6..4b725fc 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -76,10 +76,10 @@ def serialize_application(message: Inbox_Messages) -> dict: the board tag (Rozee, Mustakbil, Employee Referral, ...) lands. The tab also wants ats_score, phone, experience, recruiter, duplicate and a - processing state beyond read/unread. inbox_messages has no columns for any of - those, so they come back null instead of invented — see the note in - inbox/file_decoder.py. `processing` is derived from message_read alone, so it - is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column. + processing state beyond read/unread. phone comes from candidate_phone_number + (filled by the match task); ats_score/recruiter/duplicate stay null until + columns exist. `processing` is derived from message_read alone, so it is only + ever "Unread" or "Read"; Imported/Processed/Rejected need a column. """ return { "id": str(message.id), @@ -96,8 +96,9 @@ def serialize_application(message: Inbox_Messages) -> dict: "has_attachment": message.attachment, "resume_text": message.resume_text, "ats_score": None, - "phone": None, + "phone": message.candidate_phone_number, "experience": message.experience or "", + "current_employment": message.current_employment or "", "recruiter": None, "duplicate": None, } diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index 2d59cc9..ae48a0b 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -7,8 +7,9 @@ from datetime import datetime,timezone from agent.execute_agent import run_agent from db_setup import session_scope +from employment_agent.execute_agent import run_employment_agent from inbox.models import Inbox_Messages -from inbox.plugins import extract_resume_text +from inbox.plugins import extract_phone,extract_resume_text from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker @@ -50,6 +51,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict: job_posts=[serialize_job_post(p) for p in posts] text,extract_err=await extract_resume_text(paths) + phone=extract_phone(text) if text else None if not text: async with session_scope() as session: await Inbox_Messages.set_match_result( @@ -62,16 +64,26 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict: if status=="failed": raise RuntimeError(result.get("error") or "agent returned failed status") + current_employment,education=await run_employment_agent(resume_text=text) + async with session_scope() as session: await Inbox_Messages.set_match_result( session, record_id, resume_text=text, experience=result.get("experience") or "", + candidate_phone_number=phone, + current_employment=current_employment, + candidate_education=education, suggested_job_post_ids=result.get("suggested_job_post_ids") or [], summary=result.get("summary") or "", reasoning=result.get("reasoning") or "", status=status, error=result.get("error") or "", ) - return {"status":status,"suggested_job_post_ids":result.get("suggested_job_post_ids") or []} + return { + "status":status, + "suggested_job_post_ids":result.get("suggested_job_post_ids") or [], + "current_employment":current_employment, + "education":education, + } diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 482916b..5312f26 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1,6 +1,67 @@ -# from sqlmodel import SQLModel, Field -# from uuid import UUID, uuid4 -# from datetime import datetime -# from enum import Enum +from time import timezone +from token import OP +from sqlmodel import SQLModel, Field +from uuid import UUID, uuid4 +from datetime import datetime +from enum import Enum +import uuid +from typing import Optional,List +from sqlmodel import Relationship +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from inbox.models import Inbox + from users.models import Users -# class CV_extraction(SQLModel,table=True): +class Interviews(SQLModel,table=True): + __tablename__ = "interviews" + id: UUID = Field(default_factory=uuid4, primary_key=True) + interview_date: datetime = Field(default_factory=datetime.now) + interview_time: datetime = Field(default_factory=datetime.now) + interview_type: str = Field(default="") + interview_status: str = Field(default="") + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="interview", + sa_relationship_kwargs={"lazy": "joined"} + ) + + +class Notes(SQLModel,table=True): + __tablename__ = "notes" + id: UUID = Field(default_factory=uuid4, primary_key=True) + note: str = Field(default="") + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="notes", + sa_relationship_kwargs={"lazy": "joined"} + ) + user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id") + user: Optional[Users] = Relationship(back_populates="notes", + sa_relationship_kwargs={"lazy": "joined"} + ) + +class Activity(SQLModel,table=True): + __tablename__ = "activity" + id: UUID = Field(default_factory=uuid4, primary_key=True) + activity_type: str = Field(default="") + activity_date: datetime = Field(default_factory=datetime.now) + activity_time: datetime = Field(default_factory=datetime.now) + activity_status: str = Field(default="") + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="activity", + sa_relationship_kwargs={"lazy": "joined"} + ) + +class Feedback(SQLModel,table=True): + __tablename__ = "feedback" + id: UUID = Field(default_factory=uuid4, primary_key=True) + review: str = Field(default="") + financial_status: str = Field(default="") + score: float = Field(default=0.0) + note:str=Field(default=None) + created_at:datetime=Field(default_factory=datetime.now(timezone.utc)) + updated_at:datetime=Field(default_factory=datetime.now(timezone.utc)) + reviewed_by:Optional[uuid.UUID]=Field(default=None,foreign_key="users.id") + user:Optional[Users]=Relationship(back_populates="feedback", + sa_relationship_kwargs={"lazy":"joined"} + ) \ No newline at end of file diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 9e6f6b7..79b80fd 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -20,6 +20,7 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s "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, "resume_text": message.resume_text if message else None, "suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [], "match_summary": message.match_summary if message else None, diff --git a/backend/users/models.py b/backend/users/models.py index bbfa0a1..d578548 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,18 +1,19 @@ import uuid from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING,List,Optional from sqlalchemy import func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select +from backend.job.candidate.models import Feedback from role.models import Roles from job.job_post.models import JobPosts if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module from inbox.models import Inbox - + from job.candidate.models import Notes class Users(SQLModel, table=True): __tablename__ = "users" @@ -26,15 +27,23 @@ class Users(SQLModel, table=True): # selectin, not joined: this is a one-to-many, so a joined load would repeat the # user row once per post. Without an explicit strategy the default is a lazy load, # which raises MissingGreenlet the moment anything touches it under asyncio. - job_posts: list[JobPosts] = Relationship( + job_posts: List[JobPosts] = Relationship( back_populates="user", sa_relationship_kwargs={"lazy": "selectin"}, ) - inbox: list["Inbox"] = Relationship( + inbox: List["Inbox"] = Relationship( back_populates="user", sa_relationship_kwargs={"lazy": "selectin"}, ) + feedback:Optional[Feedback]=Relationship(back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + notes: Optional[Notes] = Relationship(back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + password: str + created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) is_active: bool = Field(default=False)