pull/9/head
ahmed.mujtaba 2026-08-11 16:42:55 +05:00
parent 3e87e1a0bc
commit e717dbf704
10 changed files with 295 additions and 19 deletions

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"]

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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"}
)

View File

@ -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,

View File

@ -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)