diff --git a/backend/agent/decorators.py b/backend/agent/decorators.py index a5c0079..53bd62c 100644 --- a/backend/agent/decorators.py +++ b/backend/agent/decorators.py @@ -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() diff --git a/backend/agent/models.py b/backend/agent/models.py index fa4e5b6..51fb99a 100644 --- a/backend/agent/models.py +++ b/backend/agent/models.py @@ -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 diff --git a/backend/agent/prompt.py b/backend/agent/prompt.py index 25e979a..0484963 100644 --- a/backend/agent/prompt.py +++ b/backend/agent/prompt.py @@ -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" } """ diff --git a/backend/agent/serializers.py b/backend/agent/serializers.py index 163027e..347f7e0 100644 --- a/backend/agent/serializers.py +++ b/backend/agent/serializers.py @@ -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 "", } diff --git a/backend/agent/views.py b/backend/agent/views.py index 0a6a867..8520014 100644 --- a/backend/agent/views.py +++ b/backend/agent/views.py @@ -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 "", } diff --git a/backend/inbox/app.py b/backend/inbox/app.py index dce4c6c..6d4321e 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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 diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 75b02a6..97f9699 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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): diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 45294fe..5176f49 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -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: diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index fcab277..4419fd6 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, } diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index c0d65cb..554b8f6 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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 "", diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 3855cc0..e7d88b1 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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) diff --git a/backend/job/app.py b/backend/job/app.py index ce910a2..cdeb38c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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)) + \ No newline at end of file diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 5c09149..0307168 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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)) \ No newline at end of file