pull/9/head
parent
3e87e1a0bc
commit
e717dbf704
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue