From d00ebcf6f5365638bf1661a385c9144089b7c1b0 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 18:42:15 +0500 Subject: [PATCH 1/7] filter appliede --- backend/g_sheet/app.py | 9 ++- backend/g_sheet/models.py | 38 +++++++++-- backend/g_sheet/views.py | 9 +-- backend/inbox/app.py | 31 ++++++--- backend/inbox/models.py | 78 +++++++++++++++++++-- backend/inbox/serializers.py | 12 ++++ backend/inbox/tasks.py | 92 ++++++++++++++++++------- backend/inbox/views.py | 76 +++++++++++++++++---- frontend/src/api/inbox.js | 11 +-- frontend/src/api/sheet.js | 8 +-- frontend/src/app/Sidebar.jsx | 1 + frontend/src/app/routes.js | 2 +- frontend/src/screens/Inbox.jsx | 121 ++++++++++++++++++++++++++++----- 13 files changed, 394 insertions(+), 94 deletions(-) diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 583b479..b70074c 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -209,6 +209,8 @@ async def fetch_form_data( has_linkedin: bool | None = Query(None), has_resume: bool | None = Query(None), city: str | None = Query(None), + source: str | None = Query(None), + assigned: bool | None = Query(None), no_suggestions: bool | None = Query(None), offset: int = Query(0,ge=0), # Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. @@ -222,7 +224,8 @@ async def fetch_form_data( sheet=sheet,search=search,offset=offset,limit=limit, processing_state=processing_state,is_duplicate=is_duplicate, has_linkedin=has_linkedin,has_resume=has_resume, - city=_city_values(city),no_suggestions=no_suggestions, + city=_city_values(city),source=(source or "").strip() or None, + assigned=assigned,no_suggestions=no_suggestions, ) return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: @@ -242,6 +245,8 @@ async def fetch_form_data_counts( has_linkedin: bool | None = Query(None), has_resume: bool | None = Query(None), city: str | None = Query(None), + source: str | None = Query(None), + assigned: bool | None = Query(None), current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), ): @@ -249,7 +254,7 @@ async def fetch_form_data_counts( service=SheetFormData(session=session) data=await service.get_counts( sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume, - city=_city_values(city), + city=_city_values(city),source=(source or "").strip() or None,assigned=assigned, ) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 01a407a..d6374ad 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -129,7 +129,8 @@ class FormData(SQLModel, table=True): @classmethod def _filters( cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None, - has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None, + has_linkedin=None, has_resume=None, city=None, source=None, assigned=None, + no_suggestions=None, inbox_filter=None, ): filters = [] if sheet: @@ -161,6 +162,16 @@ class FormData(SQLModel, table=True): if cities: city_col = func.lower(func.coalesce(cls.city, cls.residing_city)) filters.append(city_col.in_([c.lower() for c in cities])) + if source: + text = source.strip() + lowered = text.lower() + if lowered not in ("google sheet", "google_sheet", "sheet"): + filters.append(cls.source_of_application.ilike(f"%{text}%")) + if assigned is True: + filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None))) + elif assigned is False: + filters.append(cls.assigned_job_post_id.is_(None)) + filters.append(cls.job_post_id.is_(None)) if no_suggestions is True: filters.append(cls._no_suggested_jobs()) if inbox_filter == "matched": @@ -346,7 +357,8 @@ class FormData(SQLModel, table=True): async def fetch_form_data( cls, session: AsyncSession, *, sheet=None, search=None, processing_state=None, is_duplicate=None, has_linkedin=None, - has_resume=None, city=None, no_suggestions=None, inbox_filter=None, + has_resume=None, city=None, source=None, assigned=None, + no_suggestions=None, inbox_filter=None, offset=0, limit=None, ): statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc()) @@ -354,7 +366,8 @@ class FormData(SQLModel, table=True): sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, has_linkedin=has_linkedin, has_resume=has_resume, - city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter, + city=city, source=source, assigned=assigned, + no_suggestions=no_suggestions, inbox_filter=inbox_filter, ): statement = statement.where(clause) if offset: @@ -441,14 +454,16 @@ class FormData(SQLModel, table=True): async def count_form_data( cls, session: AsyncSession, *, sheet=None, search=None, processing_state=None, is_duplicate=None, has_linkedin=None, - has_resume=None, city=None, no_suggestions=None, inbox_filter=None, + has_resume=None, city=None, source=None, assigned=None, + no_suggestions=None, inbox_filter=None, ): statement = select(func.count()).select_from(cls) for clause in cls._filters( sheet=sheet, search=search, processing_state=processing_state, is_duplicate=is_duplicate, has_linkedin=has_linkedin, has_resume=has_resume, - city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter, + city=city, source=source, assigned=assigned, + no_suggestions=no_suggestions, inbox_filter=inbox_filter, ): statement = statement.where(clause) result = await session.execute(statement) @@ -457,7 +472,7 @@ class FormData(SQLModel, table=True): @classmethod async def count_processing( cls, session: AsyncSession, *, sheet=None, search=None, - has_linkedin=None, has_resume=None, city=None, + has_linkedin=None, has_resume=None, city=None, source=None, assigned=None, ): """Tab badge counts for the Sheet Forms channel. @@ -483,6 +498,7 @@ class FormData(SQLModel, table=True): for clause in cls._filters( sheet=sheet, search=search, has_linkedin=has_linkedin, has_resume=has_resume, city=city, + source=source, assigned=assigned, ): statement = statement.where(clause) row = (await session.execute(statement)).one() @@ -512,6 +528,16 @@ class FormData(SQLModel, table=True): ) return [text.strip() for text in result.scalars().all() if (text or "").strip()] + @classmethod + async def distinct_sources(cls, session: AsyncSession): + """Non-blank source_of_application values. Distinct only within form_data.""" + result = await session.execute( + select(cls.source_of_application) + .where(cls.source_of_application.is_not(None), cls.source_of_application != "") + .distinct() + ) + return [text.strip() for text in result.scalars().all() if (text or "").strip()] + @classmethod async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True): count_result = await session.execute( diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index e232cd9..9e587cf 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -436,20 +436,20 @@ class SheetFormData(Sheet): async def get_form_data( self,sheet=None,search=None,offset=0,limit=None, processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None, - city=None,no_suggestions=None, + city=None,source=None,assigned=None,no_suggestions=None, ): session=self._require_session() rows=await FormData.fetch_form_data( session,sheet=sheet,search=search,offset=offset,limit=limit, processing_state=processing_state,is_duplicate=is_duplicate, has_linkedin=has_linkedin,has_resume=has_resume,city=city, - no_suggestions=no_suggestions, + source=source,assigned=assigned,no_suggestions=no_suggestions, ) total=await FormData.count_form_data( session,sheet=sheet,search=search, processing_state=processing_state,is_duplicate=is_duplicate, has_linkedin=has_linkedin,has_resume=has_resume,city=city, - no_suggestions=no_suggestions, + source=source,assigned=assigned,no_suggestions=no_suggestions, ) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) from job.candidate.views import CandidateView @@ -600,10 +600,11 @@ class SheetFormData(Sheet): raise HTTPException(status_code=404,detail="Form data not found") return await self.get_form_data_by_id(record_id) - async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None): + async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None): return await FormData.count_processing( self._require_session(),sheet=sheet,search=search, has_linkedin=has_linkedin,has_resume=has_resume,city=city, + source=source,assigned=assigned, ) async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None): diff --git a/backend/inbox/app.py b/backend/inbox/app.py index ed14f85..9b253da 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -26,10 +26,12 @@ def _city_values(city: str | None): return parts or None -def _apps_payload(items,total,cities=None): +def _apps_payload(items,total,cities=None,sources=None): body={"data":items,"total":total,"status_code":200} if cities is not None: body["cities"]=cities + if sources is not None: + body["sources"]=sources return JSONResponse(content=body) @@ -97,6 +99,8 @@ class ReadAllBody(BaseModel): is_duplicate: bool | None = None no_suggestions: bool | None = None processing_state: str | None = None + city: str | None = None + source: str | None = None class TriageOverrideBody(BaseModel): @@ -303,6 +307,8 @@ async def mark_all_inbox_read( is_duplicate=payload.is_duplicate, no_suggestions=payload.no_suggestions, processing_state=payload.processing_state, + city=_city_values(payload.city), + source=(payload.source or "").strip() or None, ) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) except HTTPException: @@ -338,6 +344,7 @@ async def get_all_applications( processing_state: str | None = Query(default=None), search: str | None = Query(None), city: str | None = Query(None), + source: str | None = Query(None), city_list: bool = Query(default=False), # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged. top: int | None = Query(None, ge=1, le=500), @@ -348,23 +355,25 @@ async def get_all_applications( try: service=Email(session=session) city_values=_city_values(city) + source_value=(source or "").strip() or None cities=await service.list_cities() if city_list else None + sources=await service.list_sources() if city_list else None if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state: - items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) - total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) - return _apps_payload(items,total,cities) + items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) + total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) + return _apps_payload(items,total,cities,sources) if isread==False: - items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) - total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) - return _apps_payload(items,total,cities) + items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) + total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) + return _apps_payload(items,total,cities,sources) if record_id: item=await service.get_application_by_id(record_id) - return _apps_payload(item,1,cities) + return _apps_payload(item,1,cities,sources) - items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values) - total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values) - return _apps_payload(items,total,cities) + items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value) + total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value) + return _apps_payload(items,total,cities,sources) except HTTPException: raise except Exception as e: diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 42d3d4f..3c09a32 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -9,7 +9,7 @@ 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, case, func, or_, update +from sqlalchemy import Column, DateTime, case, false, func, or_, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -1031,6 +1031,7 @@ class Inbox_Messages(SQLModel, table=True): no_suggestions: bool | None=None, processing_state: str | None=None, city=None, + source: str | None=None, inbox_filter: str | None=None, ): """The one WHERE chain shared by the list, the count and the bulk read UPDATE. @@ -1061,6 +1062,23 @@ class Inbox_Messages(SQLModel, table=True): cities = [c.strip() for c in (city or []) if (c or "").strip()] if cities: statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities])) + if source: + text = source.strip() + lowered = text.lower() + if lowered in ("google sheet", "google_sheet", "sheet"): + statement = statement.where(false()) + else: + key = lowered.replace(" ", "_") + channel_ids = select(SourceChannels.id).where( + or_( + func.lower(SourceChannels.label) == lowered, + SourceChannels.key == key, + ) + ) + statement = statement.where(or_( + cls.source_channel_id.in_(channel_ids), + cls.message_to.ilike(f"%{text}%"), + )) if inbox_filter == "matched": statement = statement.where(cls.match_status == "matched") elif inbox_filter == "unassigned": @@ -1076,12 +1094,12 @@ class Inbox_Messages(SQLModel, table=True): @classmethod async def get_inbox_messages( - cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, light: bool=False + cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False ): statement = cls._apply_filters( select(cls).order_by(cls.created_at.desc(), cls.id.desc()), search, isread, application_status, assigned, is_duplicate, - no_suggestions, processing_state, city, + no_suggestions, processing_state, city, source, ) if skip: statement = statement.offset(skip) @@ -1162,11 +1180,11 @@ class Inbox_Messages(SQLModel, table=True): return {str(job_id): int(n) for job_id, n in result.all()} @classmethod - async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None): + async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None): statement = cls._apply_filters( select(func.count()).select_from(cls), search, isread, application_status, assigned, is_duplicate, - no_suggestions, processing_state, city, + no_suggestions, processing_state, city, source, ) result = await session.execute(statement) return result.scalar_one() @@ -1283,6 +1301,8 @@ class Inbox_Messages(SQLModel, table=True): is_duplicate: bool | None=None, processing_state: str | None=None, no_suggestions: bool | None=None, + city=None, + source: str | None=None, ) -> int: """Mark every row matching a list filter. Returns rows actually CHANGED. @@ -1291,7 +1311,10 @@ class Inbox_Messages(SQLModel, table=True): was already read. It also keeps read_overridden_at off rows nobody decided anything about, so the Outlook sweep keeps its reach over untouched mail. """ - statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,no_suggestions,processing_state) + statement=cls._apply_filters( + update(cls),search,isread,application_status,assigned,is_duplicate, + no_suggestions,processing_state,city,source, + ) statement=statement.where(cls.message_read!=bool(read)) result=await session.execute( statement.values(message_read=bool(read),read_overridden_at=_now()) @@ -1834,6 +1857,49 @@ class AtsResults(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_latest_by_job_for_inbox(cls, session: AsyncSession, inbox_id): + """Newest score per job_post_id for one inbox row (current or superseded).""" + if inbox_id is None: + return [] + result = await session.execute( + select(cls) + .where(cls.inbox_id == int(inbox_id)) + .order_by(cls.computed_at.desc()) + ) + by_job = {} + for row in result.scalars(): + jid = str(row.job_post_id) if row.job_post_id else None + if jid and jid not in by_job: + by_job[jid] = row + return list(by_job.values()) + + @classmethod + async def get_latest_by_job_for_messages(cls, session: AsyncSession, message_ids) -> dict: + """{str(message_id): [newest score per job]} for a page of inbox messages.""" + keys = [] + for raw in message_ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + keys.append(uid) + if not keys: + return {} + result = await session.execute( + select(cls, Inbox.message_id) + .join(Inbox, cls.inbox_id == Inbox.id) + .where(Inbox.message_id.in_(keys)) + .order_by(cls.computed_at.desc()) + ) + grouped = {} + for row, mid in result.all(): + if mid is None: + continue + bucket = grouped.setdefault(mid, {}) + jid = str(row.job_post_id) if row.job_post_id else None + if jid and jid not in bucket: + bucket[jid] = row + return {str(mid): list(jobs.values()) for mid, jobs in grouped.items()} + @classmethod async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id): """Any score for this form_data row against this job — current or superseded.""" diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index e22b5c7..843a9d3 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -82,6 +82,18 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict: "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, "resume_text": message.resume_text, + "ats_score": message.ats_score, + "ats_band": message.ats_band or None, + } + + +def serialize_ats_result(row) -> dict: + """One inbox ats_results row — same keys the Sheet Forms cards paint.""" + return { + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "overall_score": row.overall_score, + "band": row.band or None, + "computed_at": row.computed_at.isoformat() if row.computed_at else None, } diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index a0784d0..b7cf2bc 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -39,6 +39,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict: except ValueError: raise PermanentTaskError("record_id and job_id must be uuids") + already=False + results=[] async with session_scope() as session: link=await Inbox.get_inbox_by_message_id(session,mid) if link is not None: @@ -46,22 +48,65 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict: # a user, so already_scored must not depend on it. existing=await AtsResults.get_for_inbox_job(session,link.id,jid) if existing is not None: - return {"status":"already_scored"} - job=await JobPosts.get_job_post_by_id(session,job_id) - if job is None or job.is_deleted: - raise PermanentTaskError("job post missing or deleted") - service=CandidateScoring(session=session) - try: - # Attribute the rows to the job's owner — there is no request user - # in a background task. - results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)}) - except HTTPException as exc: - # 400/404 from score_inbox are permanent (no attachment, bad ids); - # retrying cannot fix them. - raise PermanentTaskError(str(exc.detail)) from exc + already=True + if not already: + job=await JobPosts.get_job_post_by_id(session,job_id) + if job is None or job.is_deleted: + raise PermanentTaskError("job post missing or deleted") + service=CandidateScoring(session=session) + try: + # Attribute the rows to the job's owner — there is no request user + # in a background task. + results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)}) + except HTTPException as exc: + # 400/404 from score_inbox are permanent (no attachment, bad ids); + # retrying cannot fix them. + raise PermanentTaskError(str(exc.detail)) from exc + if already: + # Assigning a job already scored as a suggestion must still flip the + # denormed chip from max-of-suggestions to that job. + await denorm_message_ats_score(record_id) + return {"status":"already_scored"} + await denorm_message_ats_score(record_id) return {"status":"scored","results":len(results)} +async def denorm_message_ats_score(record_id:str) -> None: + """Stamp inbox_messages.ats_score: assigned job if set, else max of suggestions.""" + async with session_scope() as session: + msg=await Inbox_Messages.get_inbox_message_by_id(session,record_id) + if msg is None: + return + link=await Inbox.get_inbox_by_message_id(session,record_id) + if link is None: + return + rows=await AtsResults.get_latest_by_job_for_inbox(session,link.id) + if not rows: + return + chosen=None + assigned=msg.assigned_job_post_id + if assigned: + chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None) + if chosen is None: + chosen=max(rows,key=lambda r:float(r.overall_score or 0)) + await Inbox_Messages.set_ats_score(session,record_id,chosen.overall_score,chosen.band) + + +async def score_message_against_jobs(record_id:str,job_ids) -> None: + """Score one inbox CV against each job. Failures do not abort the rest.""" + seen=set() + for raw in job_ids or []: + job_id=str(raw or "").strip() + if not job_id or job_id in seen: + continue + seen.add(job_id) + try: + outcome=await score_message_against_job(record_id,job_id) + logger.info("ats auto-score %s vs %s: %s",record_id,job_id,outcome.get("status")) + except Exception as exc: + logger.warning("ats auto-score failed for %s vs %s: %s",record_id,job_id,exc) + + @broker.task( task_name="g_sheet.score_form", retry_on_error=True, @@ -166,23 +211,18 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict: status=status, error=result.get("error") or "", ) - # Auto-score: the match just paired this CV with jobs, so run the ATS on the - # spot — assigned job first, else the agent's top suggestion. Scoring failures - # must not fail the match; the match result is already committed above. + # Auto-score every suggested job. The list chip is the max until a recruiter + # assigns one; assignment then re-runs ATS against that job_post_id. + # Scoring failures must not fail the match; the match result is committed above. suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j] - score_job_id=None + score_job_ids=list(suggested) async with session_scope() as session: fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id) if fresh is not None and fresh.assigned_job_post_id: - score_job_id=str(fresh.assigned_job_post_id) - if score_job_id is None and suggested: - score_job_id=suggested[0] - if score_job_id: - try: - outcome=await score_message_against_job(record_id,score_job_id) - logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status")) - except Exception as exc: - logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc) + assigned=str(fresh.assigned_job_post_id) + if assigned not in score_job_ids: + score_job_ids.append(assigned) + await score_message_against_jobs(record_id,score_job_ids) return { "status":status, diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 906aa90..b2e35ba 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -4,9 +4,9 @@ import uuid import httpx,os from fastapi import HTTPException from inbox.enums import Candidate_application_Status -from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox +from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox,SourceChannels,AtsResults from inbox.file_decoder import extract_pdf_attachments -from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run +from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run, serialize_ats_result from inbox.plugins import ( EMAIL_API_TOKEN, attach_email_pdfs_to_s3, @@ -272,15 +272,16 @@ class Email: item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id) else: item["assigned_job_post"]=None - return await cv.attach_application_history(item) + items=await self._paint_inbox_ats([item]) + return await cv.attach_application_history(items[0]) - async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None): + async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state: - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) elif isread==False: - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) else: - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages] items=await self._attach_job_posts(items) @@ -323,6 +324,46 @@ class Email: else: suggested.append(dict(payload)) item["suggested_job_posts"]=suggested + items=await self._paint_inbox_ats(items) + return items + + async def _paint_inbox_ats(self,items): + """Attach per-job ATS scores onto suggested/assigned posts and stamp max. + + Unassigned rows show the highest suggestion score; assigned rows show + the score against assigned_job_post_id — same rule as Sheet Forms. + """ + if not items: + return items + by_msg=await AtsResults.get_latest_by_job_for_messages( + self.session,[item.get("id") for item in items], + ) + for item in items: + rows=by_msg.get(str(item.get("id") or "")) or [] + scores=[serialize_ats_result(r) for r in rows] + item["ats_results"]=scores + score_by_job={ + str(s["job_post_id"]):s for s in scores if s.get("job_post_id") + } + for post in item.get("suggested_job_posts") or []: + hit=score_by_job.get(str(post.get("id"))) + if hit: + post["overall_score"]=hit.get("overall_score") + post["band"]=hit.get("band") + assigned=item.get("assigned_job_post") + if assigned: + hit=score_by_job.get(str(assigned.get("id"))) + if hit: + assigned["overall_score"]=hit.get("overall_score") + assigned["band"]=hit.get("band") + aid=item.get("assigned_job_post_id") + assigned_score=score_by_job.get(str(aid)) if aid else None + if assigned_score and assigned_score.get("overall_score") is not None: + item["ats_score"]=round(float(assigned_score.get("overall_score"))) + else: + nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None] + if nums: + item["ats_score"]=round(float(max(nums))) return items async def get_application_by_id(self,record_id): @@ -478,13 +519,13 @@ class Email: results.append({"email":email,"sent":False}) return results - async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None): + async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None): if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state: - return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) + return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) elif isread==False: - return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) + return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) else: - return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) + return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) async def list_cities(self): """Distinct cities from inbox_messages and form_data, merged in Python.""" @@ -493,6 +534,15 @@ class Email: forms=await FormData.distinct_cities(self.session) return Reapplied(session=self.session).merge_cities(inbox,forms) + async def list_sources(self): + """Source / platform labels: seeded channels, Google Sheet, form sources.""" + from g_sheet.models import FormData + channels=await SourceChannels.list_active(self.session) + forms=await FormData.distinct_sources(self.session) + return Reapplied(session=self.session).merge_cities( + [c.label for c in channels],["Google Sheet"],forms, + ) + async def assign_job_post(self,record_id,job_post_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) if not message: @@ -550,7 +600,8 @@ class Email: async def set_read_all(self,read,search=None,isread:bool=True, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, - assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None): + assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None, + city=None,source=None): """Mark every row the SAME filter set would have listed. The filter arguments are the caller's current view, not a free-form query: the @@ -561,6 +612,7 @@ class Email: self.session,read,search=search,isread=isread, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, no_suggestions=no_suggestions,processing_state=processing_state, + city=city,source=source, ) logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s", updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate) diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index cc7dd52..ef00b41 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -20,7 +20,7 @@ export function listMessages() { * `assigned` is tri-valued: omit for no filter, true for rows with an * assigned_job_post_id, false for the Job Matching queue. */ -export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, cityList } = {}) { +export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) { return request('/inbox/all-applications', { // `isread` is tri-valued on the wire: omit it for every tab (server defaults // to true = no filter), send false for the Unread tab only. buildUrl drops @@ -31,8 +31,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat // `no_suggestions`: Inbox On-Hold tab — no suggested job post linked. // `processing_state`: Processed / Rejected tabs (Move to Shortlist writes // processed, not application_status PROCESS). - // `city`: optional comma-separated list. `city_list`: include merged distinct - // cities from inbox_messages and form_data on the same response. + // `city`: optional comma-separated list. `source`: channel / platform label. + // `city_list`: include merged distinct cities and sources on the same response. params: { search, top, @@ -45,6 +45,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat no_suggestions: noSuggestions, processing_state: processingState, city, + source, city_list: cityList, }, }) @@ -128,7 +129,7 @@ export function bulkSetRead(recordIds, read) { * Resolves to `{updated, read}`, where `updated` counts rows that actually * CHANGED state, so it is safe to show in a toast. */ -export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) { +export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) { return request('/inbox/read-all', { method: 'PATCH', body: { @@ -140,6 +141,8 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned, is_duplicate: isDuplicate, no_suggestions: noSuggestions, processing_state: processingState, + city, + source, }, }) } diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 73362ea..20e3969 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -24,12 +24,12 @@ export function listFormDataSheets() { */ export function listFormData({ sheet, search, offset = 0, limit, processing_state, is_duplicate, - hasLinkedin, hasResume, city, no_suggestions, + hasLinkedin, hasResume, city, source, assigned, no_suggestions, } = {}) { return request('/sheet/form-data/fetch', { params: { sheet, search, offset, limit, processing_state, is_duplicate, - has_linkedin: hasLinkedin, has_resume: hasResume, city, no_suggestions, + has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions, }, }) } @@ -47,9 +47,9 @@ export function countFormData({ sheet } = {}) { * processing_state or is_duplicate: those two ARE the tabs, and passing them * would make every badge report the tab the user is already on. */ -export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) { +export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) { return request('/sheet/form-data/counts', { - params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city }, + params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned }, }) } diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx index 1bf3e62..0cd8dde 100644 --- a/frontend/src/app/Sidebar.jsx +++ b/frontend/src/app/Sidebar.jsx @@ -11,6 +11,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge // A group heading renders only if something under it survived the permission // filter — otherwise a low-privilege user sees orphaned section labels. const visible = ROUTES.filter((r) => { + if (r.hidden) return false if (!can(r.permission)) return false if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false return true diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index 6ad2ad0..b3a3a11 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -18,7 +18,7 @@ export const ROUTES = [ // --- Workspace --- { path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' }, { path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' }, - { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' }, + { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching', hidden: true }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, // Replaces Talent Pool. That screen browsed candidate accounts over a seed diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index a6c91c6..d69c17a 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -72,7 +72,7 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } const SEARCH_DEBOUNCE_MS = 300 /** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */ -const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' } +const EMPTY_INBOX_FILTERS = { location: '', source: '', assigned: '', hasLinkedin: '', hasResume: '' } /** "Updated 4 min ago" under the search box — the honest label for a cached list. */ function agoLabel(ts) { @@ -254,6 +254,22 @@ function asAtsScore(value) { return Number.isFinite(n) ? n : null } +/** Assigned score if set, else max of per-job suggestion scores. */ +function emailAtsScore(row) { + const assignedId = row?.assigned_job_post_id || row?.assignedId + const results = Array.isArray(row?.ats_results) ? row.ats_results : [] + const posts = Array.isArray(row?.suggested_job_posts) + ? row.suggested_job_posts + : (Array.isArray(row?.suggestedPosts) ? row.suggestedPosts : []) + const assignedScore = results.find((s) => String(s.job_post_id) === String(assignedId)) + const fromResults = results.map((s) => asAtsScore(s.overall_score)).filter((n) => n != null) + const fromJobs = posts.map((p) => asAtsScore(p.overall_score)).filter((n) => n != null) + return asAtsScore(row?.ats_score) + ?? asAtsScore(assignedScore?.overall_score) + ?? (fromResults.length ? Math.max(...fromResults) : null) + ?? (fromJobs.length ? Math.max(...fromJobs) : null) +} + /** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */ function assignedJobTitle(row) { const fromPost = row?.assignedPost?.title @@ -493,6 +509,8 @@ async function fetchMessageDetail(recordId) { suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedPost: row.assigned_job_post || null, + atsResults: Array.isArray(row.ats_results) ? row.ats_results : [], + atsScore: emailAtsScore(row), isReapplicant: Boolean(row.is_reapplicant), previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } @@ -536,7 +554,7 @@ async function fetchApplications(params) { linkedinUrl: row.linkedin_url || '', // resume_text is not on the list payload (serialize_application // light=True). The detail query fetches it for the open row. - atsScore: asAtsScore(row.ats_score), + atsScore: emailAtsScore(row), phone: row.phone, experience: row.experience, recruiter: row.recruiter, @@ -549,6 +567,7 @@ async function fetchApplications(params) { suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedPost: row.assigned_job_post || null, + atsResults: Array.isArray(row.ats_results) ? row.ats_results : [], isReapplicant: Boolean(row.is_reapplicant), previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } @@ -892,10 +911,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, * `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into * about four hundred pixels. This stacks instead, like the Progress sidebar. * - * Location is on every channel. LinkedIn / Resume stay Sheet Forms only — those - * columns do not exist on email rows. Collapsed by default because extra selects - * eat scarce vertical space above the queue, but the toggle carries the active - * count so a collapsed panel cannot silently hide a filter. + * City and Source / Platform are on every channel. Assigned job is too — + * email and form rows both carry an assigned_job_post_id. LinkedIn / Resume + * stay Sheet Forms only — those columns do not exist on email rows. Collapsed + * by default because extra selects eat scarce vertical space above the queue, + * but the toggle carries the active count so a collapsed panel cannot silently + * hide a filter. * * (`Facet` in Candidates.jsx and CvBank.jsx is the same idea in a wider column. * Both files are mid-edit elsewhere, so this is deliberately a local copy rather @@ -904,9 +925,10 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, /** Junk location tokens — hide from the dropdown, not from the list query. */ const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i -function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) { +function InboxFilters({ filters, cities, sources, showLinkFilters, active, open, onToggle, onChange, onClear }) { const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim())) const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim()) + const platforms = sources || [] return (
- ) : selected?.kind === 'form' ? ( - importItem(selected)} - onMove={() => moveToPipeline(selected)} - onNote={() => setNoting(selected)} - onReject={() => reject(selected)} - onToggleDuplicate={() => toggleDuplicate(selected)} - /> ) : ( - importItem(selected)} - onMove={() => moveToPipeline(selected)} - onNote={() => setNoting(selected)} - onReject={() => reject(selected)} - onToggleDuplicate={() => toggleDuplicate(selected)} - /> +
+ {selected?.kind === 'form' ? ( + importItem(selected)} + onMove={() => moveToPipeline(selected)} + onNote={() => setNoting(selected)} + onReject={() => reject(selected)} + onToggleDuplicate={() => toggleDuplicate(selected)} + /> + ) : ( + importItem(selected)} + onMove={() => moveToPipeline(selected)} + onNote={() => setNoting(selected)} + onReject={() => reject(selected)} + onToggleDuplicate={() => toggleDuplicate(selected)} + /> + )} +
)} diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index fdaadb5..b74a459 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1032,6 +1032,14 @@ canvas { width: 100%; max-width: 100%; display: block; } .inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); } /* Desktop keeps the two-pane split; only the phone layout below shows it. */ .inbox-back { display: none; } +/* Detail pane is painted from the list row before GET-by-id returns. Block + every control inside it until that payload lands — the queue list stays live. */ +.inbox-detail-pending { + pointer-events: none; + user-select: none; + opacity: 0.55; + cursor: wait; +} /* Phones: the stacked split nested two scroll wells inside the page scroll. Instead: one page scroll, and a selected application takes over from the list — the Back button returns to it (standard master-detail collapse). */ -- 2.40.1 From 7c9e18741d80ad3f5b46b3e7692307be16ac4920 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 18:55:42 +0500 Subject: [PATCH 3/7] applied 10 cache --- backend/g_sheet/app.py | 4 ---- frontend/src/screens/Inbox.jsx | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index b70074c..012698d 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -203,9 +203,6 @@ async def fetch_form_data( search: str | None = Query(None), processing_state: str | None = Query(None), is_duplicate: bool | None = Query(None), - # Tri-valued, like is_duplicate above: omit for no filter, true for rows that - # have the link, false for the ones missing it. Chasing the gaps is half the - # reason these exist, so `false` has to be a real filter and not "unset". has_linkedin: bool | None = Query(None), has_resume: bool | None = Query(None), city: str | None = Query(None), @@ -213,7 +210,6 @@ async def fetch_form_data( assigned: bool | None = Query(None), no_suggestions: bool | None = Query(None), offset: int = Query(0,ge=0), - # Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. limit: int | None = Query(None,ge=1,le=500), current_user: dict = Depends(_FORM_DATA_READ), session: AsyncSession = Depends(get_session), diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 62e798a..09d52bc 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -68,6 +68,43 @@ const LIST_CACHE = { } const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } +/** + * Last 10 opened applicant details. The queue list is a light row; GET-by-id + * is the body, suggested roles, resume text. Re-opening one of these should + * not pay that trip again. Cap is LRU — the 11th open drops the oldest from + * the query cache. Mutations that write THIS row still invalidate its key. + */ +const OPENED_DETAIL_CAP = 10 +const openedDetailLru = [] + +function detailQueryKey(kind, id) { + return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id) +} + +function rememberOpenedDetail(qc, kind, id) { + if (!id) return + const sig = `${kind}:${id}` + const key = detailQueryKey(kind, id) + const next = openedDetailLru.filter((x) => x.sig !== sig) + next.push({ sig, kind, id, key }) + while (next.length > OPENED_DETAIL_CAP) { + const dropped = next.shift() + if (dropped && dropped.sig !== sig) { + qc.removeQueries({ queryKey: dropped.key }) + } + } + openedDetailLru.length = 0 + openedDetailLru.push(...next) +} + +const DETAIL_CACHE = { + staleTime: Infinity, + gcTime: INBOX_GC_MS, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} + /** Typing must not put a query key (and a skeleton) on screen per keystroke. */ const SEARCH_DEBOUNCE_MS = 300 @@ -1388,7 +1425,12 @@ export default function Inbox() { queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), + ...DETAIL_CACHE, }) + useEffect(() => { + if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return + rememberOpenedDetail(qc, selectedKind, selectedId) + }, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data]) // List rows paint immediately; GET-by-id is the full application. Block every // control in the detail pane until that fetch succeeds. The queue stays live. const detailLocked = Boolean(selectedId) && !detailQuery.isError && !detailQuery.isSuccess -- 2.40.1 From ad6f8ba12153a976595c4ba6145c5b24094b0ec5 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 19:32:49 +0500 Subject: [PATCH 4/7] s3 lionk done --- backend/g_sheet/views.py | 10 ++--- backend/inbox/plugins.py | 18 +-------- backend/inbox/views.py | 31 ++++---------- backend/job/job_post/models.py | 27 ++++++++++++- backend/job/job_post/serializers.py | 11 +++++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 34 +++++++++++++--- frontend/src/ui/SuggestedRoles.jsx | 63 ++++++++++++++++++++++++----- 8 files changed, 133 insertions(+), 62 deletions(-) diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 9e587cf..423b768 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -340,23 +340,23 @@ class SheetFormData(Sheet): """FormData DB mirror — query / delete only (no Google client).""" async def _hydrate_job_posts(self,items): - """Attach suggested job_posts, assigned_job_post, and per-job ATS scores. + """Attach suggested job titles, assigned_job_post, and per-job ATS scores. Preferred source is suggested_job_post_ids (ILIKE matches stored on import). Legacy rows without that list still title-match. ATS is one - current score per (form, job). + current score per (form, job). Full JD loads when a card is expanded. """ if not items: return items from g_sheet.scoring import serialize_form_ats from inbox.models import AtsResults from job.job_post.models import JobPosts - from job.job_post.serializers import serialize_job_post + from job.job_post.serializers import serialize_job_post_title session=self._require_session() def _job_payload(post): - payload=serialize_job_post(post) + payload=serialize_job_post_title(post) if post.is_deleted or not post.is_active: payload={**payload,"unavailable":True} return payload @@ -374,7 +374,7 @@ class SheetFormData(Sheet): wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids])) by_id={} if wanted: - for post in await JobPosts.get_by_ids(session,wanted,active_only=False): + for post in await JobPosts.titles_by_ids(session,wanted,active_only=False): by_id[str(post.id)]=_job_payload(post) titles=[(item.get("position_applied_for") or "").strip() for item in items] diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 9e688cd..88ae31d 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import base64 import logging import os import uuid @@ -138,6 +137,7 @@ def load_file_bytes(path_or_url: str) -> bytes | None: def load_message_files(message:Inbox_Messages) -> list[dict]: + """Filename + public URL only. Open-resume uses the S3 link; do not pull bytes.""" if not message.file_path: return [] names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()] @@ -147,22 +147,6 @@ def load_message_files(message:Inbox_Messages) -> list[dict]: entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0} if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"): entry["url"]=path_str - raw=load_file_bytes(path_str) - if raw is not None: - entry["content_base64"]=base64.b64encode(raw).decode("ascii") - entry["size"]=len(raw) - files.append(entry) - continue - path=resolve_attachment_path(path_str) - if not path.is_file(): - continue - try: - raw=path.read_bytes() - except OSError: - continue - entry["file_name"]=path.name - entry["content_base64"]=base64.b64encode(raw).decode("ascii") - entry["size"]=len(raw) files.append(entry) return files diff --git a/backend/inbox/views.py b/backend/inbox/views.py index b2e35ba..1a0df7f 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -256,23 +256,7 @@ class Email: item["files"]=files from job.candidate.views import CandidateView cv=CandidateView(session=self.session) - suggested=[] - for job_id in item.get("suggested_job_post_ids") or []: - jp=await cv.get_job_post_by_id(record_id=job_id) - if jp: - if jp.get("is_deleted") or not jp.get("is_active"): - suggested.append({**jp,"unavailable":True}) - else: - suggested.append(jp) - else: - suggested.append({"id":str(job_id),"unavailable":True}) - item["suggested_job_posts"]=suggested - assigned_id=item.get("assigned_job_post_id") - if assigned_id: - item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id) - else: - item["assigned_job_post"]=None - items=await self._paint_inbox_ats([item]) + items=await self._attach_job_posts([item]) return await cv.attach_application_history(items[0]) async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None): @@ -289,10 +273,11 @@ class Email: return await CandidateView(session=self.session).attach_application_history(items) async def _attach_job_posts(self,items): - """List payload needs assigned + suggested job objects — export reads titles. + """List payload needs assigned + suggested titles — export reads names. - Detail hydrates one row; the queue used to ship ids only. Assigned job and - Suggested jobs in the Inbox .xlsx were then blank for email applicants. + Detail hydrates one row. Full JD (location, requirements) loads when + the recruiter expands a card. Assigned job and Suggested jobs in the + Inbox .xlsx stay as titles. """ ids=[] for item in items: @@ -305,9 +290,9 @@ class Email: by_id={} if ids: from job.job_post.models import JobPosts - from job.job_post.serializers import serialize_job_post - for post in await JobPosts.get_by_ids(self.session,ids,active_only=False): - payload=serialize_job_post(post) + from job.job_post.serializers import serialize_job_post_title + for post in await JobPosts.titles_by_ids(self.session,ids,active_only=False): + payload=serialize_job_post_title(post) if post.is_deleted or not post.is_active: payload={**payload,"unavailable":True} by_id[str(post.id)]=payload diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index c2982ae..fafa934 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, load_only from sqlmodel import Field, Relationship, SQLModel, select from job.job_post.enums import RequisitionStatus @@ -127,6 +127,31 @@ class JobPosts(SQLModel, table=True): # Preserve request order so suggestion ranks stay stable. return [by_id[str(u)] for u in uids if str(u) in by_id] + @classmethod + async def titles_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = False): + """id + title + status only — inbox suggestion rail before a card expands. + + Skips description / post_text TOAST columns. Rank order matches `ids`. + """ + uids = [] + for raw in ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + uids.append(uid) + if not uids: + return [] + statement = ( + select(cls) + .options(load_only(cls.id, cls.title, cls.status, cls.is_active, cls.is_deleted)) + .where(cls.id.in_(uids)) + ) + if active_only: + statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 + result = await session.execute(statement) + rows = list(result.scalars().all()) + by_id = {str(r.id): r for r in rows} + return [by_id[str(u)] for u in uids if str(u) in by_id] + @classmethod async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False): """Match job posts whose title equals any of `titles` (trim + case-insensitive). diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 5cf3ef2..608f785 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -8,6 +8,17 @@ def _status_label(value): return parsed.label if parsed else value +def serialize_job_post_title(row) -> dict: + """Inbox suggestion rail — title only until the recruiter expands the card.""" + return { + "id": str(row.id), + "title": row.title, + "status": row.status, + "is_active": row.is_active, + "is_deleted": row.is_deleted, + } + + def serialize_job_post(row) -> dict: return { "id": str(row.id), diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 0b8e962..ff2e8b6 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -78,6 +78,7 @@ export const qk = { jobPosts: { all: () => ['jobPosts'], list: (p = {}) => ['jobPosts', 'list', p], + detail: (id) => ['jobPosts', 'detail', id], departments: (p = {}) => ['jobPosts', 'departments', p], }, jobs: { diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 09d52bc..4a2631c 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -71,22 +71,39 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } /** * Last 10 opened applicant details. The queue list is a light row; GET-by-id * is the body, suggested roles, resume text. Re-opening one of these should - * not pay that trip again. Cap is LRU — the 11th open drops the oldest from - * the query cache. Mutations that write THIS row still invalidate its key. + * not pay that trip again for 15 minutes. Cap is LRU — the 11th open drops + * the oldest. After the TTL the snapshot is dropped so the next open refetches. + * Mutations that write THIS row still invalidate its key. */ const OPENED_DETAIL_CAP = 10 +const OPENED_DETAIL_TTL_MS = 15 * 60_000 const openedDetailLru = [] function detailQueryKey(kind, id) { return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id) } +function pruneOpenedDetailLru(qc, keepSig) { + const cutoff = Date.now() - OPENED_DETAIL_TTL_MS + const next = [] + for (const x of openedDetailLru) { + if (x.at > cutoff || x.sig === keepSig) { + next.push(x) + continue + } + qc.removeQueries({ queryKey: x.key }) + } + openedDetailLru.length = 0 + openedDetailLru.push(...next) +} + function rememberOpenedDetail(qc, kind, id) { if (!id) return const sig = `${kind}:${id}` const key = detailQueryKey(kind, id) + pruneOpenedDetailLru(qc, sig) const next = openedDetailLru.filter((x) => x.sig !== sig) - next.push({ sig, kind, id, key }) + next.push({ sig, kind, id, key, at: Date.now() }) while (next.length > OPENED_DETAIL_CAP) { const dropped = next.shift() if (dropped && dropped.sig !== sig) { @@ -98,9 +115,9 @@ function rememberOpenedDetail(qc, kind, id) { } const DETAIL_CACHE = { - staleTime: Infinity, - gcTime: INBOX_GC_MS, - refetchOnMount: false, + staleTime: OPENED_DETAIL_TTL_MS, + gcTime: OPENED_DETAIL_TTL_MS, + refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, } @@ -1428,6 +1445,7 @@ export default function Inbox() { ...DETAIL_CACHE, }) useEffect(() => { + pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null) if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return rememberOpenedDetail(qc, selectedKind, selectedId) }, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data]) @@ -2381,6 +2399,7 @@ function FormApplicantDetail({ badge={`Match #${rank}`} selected={String(selection) === String(post.id)} onSelect={(id) => setSelection(String(id))} + lazy /> ))} {manualPost && ( @@ -2390,6 +2409,7 @@ function FormApplicantDetail({ manual selected={String(selection) === String(manualPost.id)} onSelect={(id) => setSelection(String(id))} + lazy /> )} @@ -2788,6 +2808,7 @@ function ApplicationDetail({ selected={String(selection) === String(post.id)} onSelect={(id) => setSelection(String(id))} resumeText={resumeText} + lazy /> )) )} @@ -2799,6 +2820,7 @@ function ApplicationDetail({ selected={String(selection) === String(manualPost.id)} onSelect={(id) => setSelection(String(id))} resumeText={resumeText} + lazy /> )} + )} {tag}
{title}
{unavailable ? ( @@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba {post?.overall_score != null && } {selected && } - {meta &&
{meta}
} - {!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && ( + {showBody && detailQuery.isPending && lazy && !hasBody && ( +
Loading role…
+ )} + {showBody && detailQuery.isError && lazy && ( +
{friendlyAuthError(detailQuery.error, 'Could not load this role.')}
+ )} + {showBody && meta &&
{meta}
} + {showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
- {post.requirements.slice(0, 8).map((req, i) => { + {body.requirements.slice(0, 8).map((req, i) => { const label = reqLabel(req) if (!label) return null const hit = reqInResume(req, resumeText) -- 2.40.1 From 86531c77db745a9d9986a7beaae1ac8e19d7b853 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 19:59:28 +0500 Subject: [PATCH 5/7] commitred with correct city name --- backend/employment_agent/decorators.py | 9 ++-- backend/employment_agent/execute_agent.py | 54 ++++++++++++++++++- backend/employment_agent/prompt.py | 32 ++++++++++- backend/g_sheet/models.py | 17 +++++- backend/inbox/models.py | 16 +++++- backend/inbox/views.py | 6 ++- backend/tests/test_employment_agent.py | 24 +++++++++ .../test_employment_extraction_clamps.py | 10 ++++ backend/tests/test_form_data_filters.py | 7 +++ frontend/src/screens/Inbox.jsx | 2 +- 10 files changed, 162 insertions(+), 15 deletions(-) diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 49128ae..39c4658 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -103,17 +103,14 @@ def _clean_phone(value,resume_text): def _clean_city(value,resume_text): - """Optional residence city. Sentinel / invented → None. Never rejects the CV. + """Optional residence city. Sentinel → None. Never rejects the CV. - The prompt forbids work-experience cities; this only drops a value that is - absent from the resume text or is the explicit empty sentinel. + Proper city names (Karachi, not Karachi(Malir)) come from the OpenAI parse + in run_employment_agent. This clamp does not rewrite place names. """ text=(value or "").strip() if not text or text.lower() in (NO_CITY.lower(),"none","null","n/a","-"): return None - haystack=(resume_text or "").lower() - if haystack and text.lower() not in haystack: - return None return text diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index b240284..7ecee57 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -6,15 +6,67 @@ Called from inbox.tasks.match_inbox_message; no HTTP surface. from __future__ import annotations +import json import logging from employment_agent.decorators import parse_employment_response -from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY,prompt,user_prompt +from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_CITY,NO_COMPANY,city_list_prompt,prompt,user_prompt from llm_setup import llm_call logger=logging.getLogger("employment_agent") +def parse_normalized_cities(data,fallback=None): + """Keep unique proper city names from the list-normalizer JSON.""" + rows=None + if isinstance(data,dict): + rows=data.get("cities") + if not isinstance(rows,list): + return list(fallback or []) + out=[] + seen=set() + for item in rows: + if not isinstance(item,str): + continue + text=item.strip() + if not text or text.lower() in (NO_CITY.lower(),"none","null","n/a","-"): + continue + key=text.lower() + if key in seen: + continue + seen.add(key) + out.append(text) + out.sort(key=str.lower) + return out or list(fallback or []) + + +async def normalize_cities(values): + """OpenAI: messy stored places → the same proper city names the CV agent writes.""" + places=[] + seen=set() + for raw in values or []: + text=(raw or "").strip() + if not text: + continue + key=text.lower() + if key in seen: + continue + seen.add(key) + places.append(text) + if not places: + return [] + try: + data=await llm_call( + city_list_prompt(), + json.dumps({"places":places},ensure_ascii=False), + json_mode=True, + ) + except Exception: + logger.exception("city list normalize failed") + return places + return parse_normalized_cities(data,fallback=places) + + async def run_employment_agent(*,resume_text=""): text=(resume_text or "").strip() if not text: diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index aa7ffb1..c5d0c2a 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -14,6 +14,14 @@ NO_LINKEDIN="no linkedin url mentioned" NO_PHONE="no phone number mentioned" NO_CITY="no city mentioned" +CITY_POLICY=f"""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality. +- Correct values look like "Karachi", "Lahore", "Islamabad", "Rawalpindi", "Peshawar". Not "Karachi(Malir)", not "Wah Cantt", not "Gulberg Lahore". +- If the text names a neighborhood or area of a city, return that city: "Karachi (Malir)" / "Karachi(Malir)" / "DHA Karachi" → "Karachi". "Gulberg, Lahore" → "Lahore". "F-10 Islamabad" → "Islamabad". +- Drop "Cantt" / "Cantonment": "Lahore Cantt" → "Lahore", "Rawalpindi Cantt" → "Rawalpindi", "Wah Cantt" → "Wah". +- Never concatenate two places. If the string is messy (for example "Karachi(Malir) Wah Cantt"), return the single residence city, not both strings glued together. +- Do not return province, country, street, house number, or text inside parentheses. +- Drop junk tokens such as KA, KAR, KARA, empty values, and unintelligible strings.""" + def prompt(): return f"""You are an HR-ATS recruiting assistant. @@ -57,11 +65,10 @@ linkedin_url (its own key — extract this separately from the other fields): - Never guess a slug or construct linkedin.com/in/ from the candidate's name. The stored value will be null when this sentinel is returned. city (its own key — OPTIONAL. A missing city must not fail the candidate): -- Return the city of residence only, city name alone (for example "Karachi", "Lahore", "Islamabad"), using the resume's own spelling. +{CITY_POLICY} - Extract city ONLY from the candidate's contact / location / address header (the block with name, phone, email, LinkedIn, "Address", "Location", "based in", "currently living in"). - Do NOT extract city from Work Experience. A job that lists Karachi, UAE, USA, or any other city is the employer's location, not proof the candidate lives there. - If the contact/location section does not name a city, return exactly: {NO_CITY}. Leave it blank rather than guessing from jobs, education, or nationality. -- The city string you return MUST appear verbatim (or as a clear substring) in that contact/location section of the resume text. phone (its own key — extract this separately; copy EVERY digit): - Return the candidate's own mobile / phone exactly as written, including country code when present. @@ -118,6 +125,14 @@ Example 9 — contact/location city is residence: Resume: "Ali Khan | Location: Lahore | 0321-5551234\\nExperience: Acme, Karachi, Engineer" JSON city must be "Lahore". Not "Karachi". +Example 10 — neighborhood / cantonment is not the city: +Resume: "Ali Khan | Karachi(Malir) | 0321-5551234" +JSON city must be "Karachi". Not "Karachi(Malir)" and not "Malir". + +Example 11 — do not glue two place fragments: +Resume: "Address: Karachi(Malir) Wah Cantt" +JSON city must be "Karachi" (one city). Not "Karachi(Malir) Wah Cantt" and not "Wah Cantt". + Respond with JSON only: {{ "current_employment": "Company Name", @@ -135,3 +150,16 @@ If the contact/location section has no city, city must be "{NO_CITY}" — still def user_prompt(resume_text:str) -> str: return json.dumps({"resume_text":resume_text or ""},ensure_ascii=False) + + +def city_list_prompt(): + """Map messy stored place strings to the same proper city names the CV agent writes.""" + return f"""You map messy residence strings to proper city names for an Inbox City filter. + +{CITY_POLICY} + +Input is JSON: {{"places": ["Karachi(Malir)", "Wah Cantt", "Lahore"]}} +Respond with JSON only: +{{"cities": ["Karachi", "Wah", "Lahore"]}} +Unique proper city names only. Do not copy raw neighborhood or cantonment strings into cities. +""" diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index d6374ad..8018093 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -126,6 +126,18 @@ class FormData(SQLModel, table=True): else_=False, ) + @staticmethod + def _cities_match(column, cities): + """Agent city name vs stored raw text: Karachi matches Karachi(Malir).""" + clauses = [] + for city in cities or []: + text = (city or "").strip() + if not text: + continue + safe = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + clauses.append(column.ilike(f"%{safe}%", escape="\\")) + return or_(*clauses) if clauses else None + @classmethod def _filters( cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None, @@ -160,8 +172,9 @@ class FormData(SQLModel, table=True): ) cities = [c.strip() for c in (city or []) if (c or "").strip()] if cities: - city_col = func.lower(func.coalesce(cls.city, cls.residing_city)) - filters.append(city_col.in_([c.lower() for c in cities])) + clause = cls._cities_match(func.coalesce(cls.city, cls.residing_city), cities) + if clause is not None: + filters.append(clause) if source: text = source.strip() lowered = text.lower() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 3c09a32..a1e20a2 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1022,6 +1022,18 @@ class Inbox_Messages(SQLModel, table=True): else_=False, ) + @staticmethod + def _cities_match(column, cities): + """Agent city name vs stored raw text: Karachi matches Karachi(Malir).""" + clauses = [] + for city in cities or []: + text = (city or "").strip() + if not text: + continue + safe = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + clauses.append(column.ilike(f"%{safe}%", escape="\\")) + return or_(*clauses) if clauses else None + @classmethod def _apply_filters( cls, statement, search: str | None=None, isread: bool=True, @@ -1061,7 +1073,9 @@ class Inbox_Messages(SQLModel, table=True): statement = statement.where(cls.processing_state == processing_state) cities = [c.strip() for c in (city or []) if (c or "").strip()] if cities: - statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities])) + clause = cls._cities_match(cls.city, cities) + if clause is not None: + statement = statement.where(clause) if source: text = source.strip() lowered = text.lower() diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 1a0df7f..0c0933c 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -513,11 +513,13 @@ class Email: return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) async def list_cities(self): - """Distinct cities from inbox_messages and form_data, merged in Python.""" + """Proper city names for the Inbox filter — same OpenAI mapping as CV parse.""" + from employment_agent.execute_agent import normalize_cities from g_sheet.models import FormData inbox=await Inbox_Messages.distinct_cities(self.session) forms=await FormData.distinct_cities(self.session) - return Reapplied(session=self.session).merge_cities(inbox,forms) + merged=Reapplied(session=self.session).merge_cities(inbox,forms) + return await normalize_cities(merged) async def list_sources(self): """Source / platform labels: seeded channels, Google Sheet, form sources.""" diff --git a/backend/tests/test_employment_agent.py b/backend/tests/test_employment_agent.py index 4c33cf4..0063829 100644 --- a/backend/tests/test_employment_agent.py +++ b/backend/tests/test_employment_agent.py @@ -89,3 +89,27 @@ def test_adds_scheme_and_rejects_company_page(): "", ) assert company_page["linkedin_url"] is None + + +def test_city_prompt_asks_openai_for_a_proper_city_name(): + from employment_agent.prompt import city_list_prompt, prompt + text = prompt() + assert "Karachi(Malir)" in text + assert 'JSON city must be "Karachi"' in text + assert "Return ONE proper city name only" in text + listed = city_list_prompt() + assert "Karachi(Malir)" in listed + assert '"cities"' in listed + + +def test_parse_normalized_cities_keeps_agent_names(): + from employment_agent.execute_agent import parse_normalized_cities + assert parse_normalized_cities( + {"cities": ["Karachi", "Karachi", "Lahore", "no city mentioned"]}, + fallback=["Karachi(Malir)"], + ) == ["Karachi", "Lahore"] + + +def test_parse_normalized_cities_falls_back_when_the_model_shape_is_wrong(): + from employment_agent.execute_agent import parse_normalized_cities + assert parse_normalized_cities({"oops": True}, fallback=["Karachi(Malir)"]) == ["Karachi(Malir)"] diff --git a/backend/tests/test_employment_extraction_clamps.py b/backend/tests/test_employment_extraction_clamps.py index d4bdacd..a252333 100644 --- a/backend/tests/test_employment_extraction_clamps.py +++ b/backend/tests/test_employment_extraction_clamps.py @@ -163,3 +163,13 @@ def test_existing_sentinels_still_normalize(): assert fields["current_employment"] == NO_COMPANY assert fields["education"] == EDUCATION assert fields["skills"] == ["Python"] + + +def test_city_from_the_model_is_kept_as_returned(): + resume = "Ali Khan | Karachi(Malir) | 0321-5551234" + fields = parse_employment_response({"city": "Karachi"}, resume) + assert fields["city"] == "Karachi" + + +def test_city_sentinel_is_dropped(): + assert parse({"city": "no city mentioned"})["city"] is None diff --git a/backend/tests/test_form_data_filters.py b/backend/tests/test_form_data_filters.py index 9eef6e3..8f1d9c4 100644 --- a/backend/tests/test_form_data_filters.py +++ b/backend/tests/test_form_data_filters.py @@ -96,6 +96,13 @@ class TestListAndBadgesAgree: assert name in params, f"count_processing should narrow on {name}" +class TestCity: + def test_agent_city_matches_raw_stored_text(self): + out = sql(*FormData._filters(city=["Karachi"])) + assert "ilike" in out + assert "karachi" in out + + class TestPlumbing: @pytest.mark.parametrize( "func", [FormData.fetch_form_data, FormData.count_form_data], diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 4a2631c..515f7b9 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -977,7 +977,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, * than a refactor of theirs.) */ /** Junk location tokens — hide from the dropdown, not from the list query. */ -const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i +const HIDDEN_LOCATION = /^(KA|KAR|KARA)$/i function InboxFilters({ filters, cities, sources, showLinkFilters, active, open, onToggle, onChange, onClear }) { const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim())) -- 2.40.1 From c520b2d9c256defb398d91b0767da6849c0757d9 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 20:10:45 +0500 Subject: [PATCH 6/7] ordered --- backend/job/candidate/views.py | 24 +- .../src/components/ReapplicantHistory.jsx | 102 ++++++-- frontend/src/screens/Dashboard.jsx | 233 +++++++++++++----- 3 files changed, 266 insertions(+), 93 deletions(-) diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index d0cdc73..390b001 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -1751,7 +1751,11 @@ class CandidateView: ) async def attach_application_history(self,payloads): - """Stamp is_reapplicant + previous_applications onto list/detail dicts.""" + """Stamp is_reapplicant + previous_applications onto list/detail dicts. + + ``previous_applications`` is every application for that email, including + the open row. ``is_reapplicant`` still means a *different* assigned job. + """ single=not isinstance(payloads,list) records=[payloads] if single else list(payloads or []) emails=[_payload_email(p) for p in records] @@ -1761,15 +1765,15 @@ class CandidateView: continue email=_payload_email(payload) pack=history.get(email) or {"present_in":[],"user":None,"applications":[]} - previous=[] + items=[] + reapplied=False for row in pack.get("applications") or []: - if _is_current_application(row,payload): - continue - if not _is_earlier_application(row,payload): - continue - previous.append(serialize_application_history_item(row)) - previous.sort(key=lambda r: r.get("applied_at") or "",reverse=True) + item=serialize_application_history_item(row) + items.append(item) + if is_assigned_application(item) and not _is_current_application(row,payload): + reapplied=True + items.sort(key=lambda r: r.get("applied_at") or "",reverse=True) payload["present_in"]=list(pack.get("present_in") or []) - payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous) - payload["previous_applications"]=previous + payload["is_reapplicant"]=reapplied + payload["previous_applications"]=items return records[0] if single else records diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index c25cd41..a37d583 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -48,26 +48,62 @@ export function applicationStatusLabel(status, item) { return key.charAt(0) + key.slice(1).toLowerCase() } +function historyItemsOf(row) { + if (!row) return [] + if (Array.isArray(row.previousApplications)) return row.previousApplications + if (Array.isArray(row.previous_applications)) return row.previous_applications + return [] +} + +/** Every application for this candidate, including the one currently open. */ +export function candidateApplicationsOf(row) { + if (!row) return [] + const current = currentRowIds(row) + const items = [...historyItemsOf(row)] + if (current.size && !items.some((item) => isSameApplication(item, current))) { + const self = syntheticCurrentApplication(row) + if (self) items.push(self) + } + items.sort((a, b) => { + const ac = isSameApplication(a, current) ? 1 : 0 + const bc = isSameApplication(b, current) ? 1 : 0 + if (ac !== bc) return bc - ac + return (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0) + }) + return items +} + +/** Applications other than the open row — used by the Reapplied chip. */ export function previousApplicationsOf(row) { if (!row) return [] - const raw = Array.isArray(row.previousApplications) - ? row.previousApplications - : Array.isArray(row.previous_applications) - ? row.previous_applications - : [] const current = currentRowIds(row) - const currentTs = appliedAtMs( - row.received || row.applied_at || row.entry_date || row.when || row.sentAt, - ) - const items = raw.filter((item) => { - if (current.size && isSameApplication(item, current)) return false - const t = appliedAtMs(item?.applied_at) - if (currentTs == null) return true - if (t == null) return false - return t < currentTs - }) - items.sort((a, b) => (appliedAtMs(a?.applied_at) ?? 0) - (appliedAtMs(b?.applied_at) ?? 0)) - return items + return candidateApplicationsOf(row).filter((item) => !isSameApplication(item, current)) +} + +function syntheticCurrentApplication(row) { + const assigned = row.assignedPost || row.assigned_job_post + const position = row.kind !== 'email' && row.position && row.position !== '—' ? row.position : null + const jobTitle = assigned?.title || row.job_title || row.jobTitle || row.currentTitle || position || null + const kind = row.kind + let source = row.source + if (kind === 'form') source = 'form' + else if (kind === 'email') source = 'inbox' + else if (row.manualUploadId || row.manual_upload_candidate_id) source = 'manual' + if (source && String(source).includes('@')) source = 'inbox' + const id = row.id != null && row.id !== '' ? String(row.id) : null + return { + source: source || 'inbox', + inbox_id: row.inboxId || row.inbox_id || null, + message_id: kind === 'email' ? id : (row.message_id || null), + form_data_id: kind === 'form' ? id : (row.form_data_id || null), + manual_upload_candidate_id: row.manualUploadId || row.manual_upload_candidate_id || null, + candidate_id: row.candidate_id || (kind == null && row.jobId ? row.id : null) || null, + user_id: row.userId || row.user_id || null, + job_post_id: row.assignedId || row.jobId || row.job_post_id || null, + job_title: jobTitle, + status: row.applicationStatus || row.processingState || row.status || null, + applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null, + } } function appliedAtMs(value) { @@ -152,7 +188,7 @@ export function hrefForPreviousApplication(item) { if (item.source === 'form' && item.form_data_id) { return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form` } - if (item.source === 'inbox' && item.message_id) { + if ((item.source === 'inbox' || item.source === 'filtered') && item.message_id) { return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email` } if (item.source === 'manual') { @@ -161,13 +197,22 @@ export function hrefForPreviousApplication(item) { return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}` } } + if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}` + if (item.form_data_id) { + return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form` + } + if (item.message_id) { + return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email` + } return null } -/** Full prior-job list for profile / inbox / add-candidate. */ -export function PreviousApplications({ row, title = 'Previous applications' }) { - const items = previousApplicationsOf(row) +/** Full application list for profile / inbox / add-candidate. */ +export function PreviousApplications({ row, title = 'Total applications' }) { + const items = candidateApplicationsOf(row) if (!items.length) return null + const current = currentRowIds(row) + const heading = title === 'Total applications' ? `Total applications (${items.length})` : title return (
- {title} + {heading}
{items.map((item, idx) => { const stage = applicationStatusLabel(item.status, item) const job = item.job_title || item.jobTitle || 'No job assigned' const href = hrefForPreviousApplication(item) + const isCurrent = current.size > 0 && isSameApplication(item, current) const key = [ item.source, item.inbox_id, @@ -204,6 +250,12 @@ export function PreviousApplications({ row, title = 'Previous applications' }) { item.job_post_id, idx, ].filter(Boolean).join(':') + const jobLabel = ( + <> + {job} + {isCurrent ? ' (Current)' : ''} + + ) return (
e.stopPropagation()} > - {job} + {jobLabel} ) : ( -
{job}
+
{jobLabel}
)}
{SOURCE_LABEL[item.source] || item.source || 'Application'} diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 8b74e19..1faf315 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -105,65 +105,144 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } -const PERIOD_SHEET_NAMES = { - week: 'Weekly', - month: 'Monthly', - quarter: 'Quarterly', - year: 'Yearly', +function dashExportValue(value) { + if (value == null || value === '') return '' + return value } -const PERIOD_METRICS = [ - { label: 'Open Jobs', key: 'open_jobs' }, - { label: 'Applications', key: 'total_candidates' }, - { label: 'Hires', key: 'hires' }, - { label: 'Offers Sent', key: 'offers_sent' }, - { label: 'Offers Accepted', key: 'offers_accepted' }, - { label: 'Cost per Hire', key: 'cost_per_hire', round: true }, -] - -function kpiValue(kpis, key, { round = false } = {}) { - if (!kpis || kpis[key] == null || kpis[key] === '') return '' - const n = Number(kpis[key]) - if (!Number.isFinite(n)) return kpis[key] - return round ? Math.round(n) : n -} - -async function fetchRangeSnapshot(rangeKey, department) { - const span = rangeWindow(rangeKey) - const kpisRes = await analyticsApi.kpis({ - ...span, - department: department || undefined, - }) - return { - key: rangeKey, - fromDate: span.fromDate, - toDate: span.toDate, - kpis: asObject(kpisRes?.data), - } -} - -function buildPeriodSheets({ department, snapshots, exportedAt }) { +function buildDashboardSheets({ + department, + rangeKey, + span, + exportedAt, + kpis, + jobApps, + pipeRows, + offerCounts, + attentionRows, + starvingCount, + activity, +}) { const deptLabel = department || 'All Departments' const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) - const columns = [ - { header: 'Metric', key: 'metric', width: 22 }, - { header: 'Value', key: 'value', width: 16 }, + const from = fmtDate(span.fromDate) || span.fromDate + const to = fmtDate(span.toDate) || span.toDate + const subtitle = `${rangeLabel(rangeKey)} · ${from} – ${to} · ${deptLabel} · exported ${stamp}` + const k = kpis || {} + + const summaryRows = [ + { metric: 'Open Jobs', value: dashExportValue(k.open_jobs), trend: pctDelta(k.open_jobs, k.open_jobs_prior) || '' }, + { metric: 'Applications', value: dashExportValue(k.total_candidates), trend: pctDelta(k.total_candidates, k.total_candidates_prior) || '' }, + { metric: 'Hires', value: dashExportValue(k.hires), trend: pctDelta(k.hires, k.hires_prior) || '' }, + { metric: 'Offers Sent', value: dashExportValue(k.offers_sent), trend: pctDelta(k.offers_sent, k.offers_sent_prior) || '' }, + { metric: 'Offers Accepted', value: dashExportValue(k.offers_accepted), trend: pctDelta(k.offers_accepted, k.offers_accepted_prior) || '' }, + { + metric: 'Interviews Today', + value: dashExportValue(k.interviews_today), + trend: k.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '', + }, + { + metric: 'Time to Hire', + value: k.time_to_hire != null ? `${Math.round(k.time_to_hire)} days` : '', + trend: dayDelta(k.time_to_hire, k.time_to_hire_prior) || '', + }, + { + metric: 'Cost per Hire', + value: k.cost_per_hire != null ? money(Math.round(k.cost_per_hire)) : '', + trend: pctDelta(k.cost_per_hire, k.cost_per_hire_prior) || '', + }, ] - return snapshots.map((s) => { - const name = PERIOD_SHEET_NAMES[s.key] || s.key - const from = fmtDate(s.fromDate) || s.fromDate - const to = fmtDate(s.toDate) || s.toDate - return { - name, - title: name, - subtitle: `${from} – ${to} · ${deptLabel} · exported ${stamp}`, - columns, - rows: PERIOD_METRICS.map((spec) => ({ - metric: spec.label, - value: kpiValue(s.kpis, spec.key, { round: spec.round }), + + return [ + { + name: 'Summary', + title: 'Dashboard', + subtitle, + columns: [ + { header: 'Metric', key: 'metric', width: 22 }, + { header: 'Value', key: 'value', width: 16 }, + { header: 'Vs prior period', key: 'trend', width: 18 }, + ], + rows: summaryRows, + }, + { + name: 'Applications per Job', + title: 'Applications per Job', + subtitle, + columns: [ + { header: 'Job', key: 'title', width: 36 }, + { header: 'Department', key: 'department', width: 22 }, + { header: 'Status', key: 'status', width: 14 }, + { header: 'Vacancies', key: 'vacancies', width: 12 }, + { header: 'Applications', key: 'count', width: 14 }, + ], + rows: jobApps.map((j) => ({ + title: j.title || '', + department: j.department || '', + status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '', + vacancies: j.vacancies ?? '', + count: j.count ?? 0, })), - } - }) + }, + { + name: 'Pipeline', + title: 'Candidate Pipeline', + subtitle, + columns: [ + { header: 'Stage', key: 'stage', width: 18 }, + { header: 'Count', key: 'count', width: 12 }, + { header: 'Share', key: 'share', width: 12 }, + ], + rows: pipeRows.map((r) => ({ + stage: r.stage, + count: r.count, + share: r.pct != null ? `${r.pct}%` : '', + })), + }, + { + name: 'Offer Book', + title: 'Offer Book', + subtitle: `All offers by status · point-in-time · exported ${stamp}`, + columns: [ + { header: 'Status', key: 'status', width: 18 }, + { header: 'Count', key: 'count', width: 12 }, + ], + rows: offersApi.OFFER_STATUSES.map((s) => ({ + status: offersApi.OFFER_STATUS_LABEL[s], + count: offerCounts[s] ?? 0, + })), + }, + { + name: 'Needs Attention', + title: 'Needs Attention', + subtitle: `Work queued right now · exported ${stamp}`, + columns: [ + { header: 'Item', key: 'title', width: 36 }, + { header: 'Count', key: 'count', width: 12 }, + ], + rows: [ + ...attentionRows.map((row) => ({ title: row.title, count: row.count ?? 0 })), + { title: 'Open jobs with no applications', count: starvingCount }, + ], + }, + { + name: 'Recent Activity', + title: 'Recent Activity', + subtitle: `Latest across the org · exported ${stamp}`, + columns: [ + { header: 'When', key: 'when', width: 20 }, + { header: 'Activity', key: 'activity', width: 28 }, + { header: 'Actor', key: 'actor', width: 22 }, + { header: 'Detail', key: 'detail', width: 40 }, + ], + rows: activity.map((a) => ({ + when: fmtWhen(a.activity_date), + activity: a.activity_type || 'Activity', + actor: a.actor_name || '', + detail: a.description || a.activity_status || '', + })), + }, + ] } function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { @@ -423,16 +502,54 @@ function DashboardHome() { if (exporting) return setExporting(true) try { - const snapshots = await Promise.all( - RANGES.map((r) => fetchRangeSnapshot(r.key, department)), - ) + const [kpisRes, jobAppsRes, funnelRes, offersRes, countsRes, activityRes] = await Promise.all([ + analyticsApi.kpis(filters), + analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }), + analyticsApi.funnel(filters), + offersApi.list({ top: 500 }).catch(() => null), + inboxApi.fetchCounts().catch(() => ({})), + activityApi.feed({ top: 8 }).catch(() => null), + ]) + const kpis = asObject(kpisRes?.data) + const jobApps = asList(jobAppsRes?.data) + const funnel = asList(funnelRes?.data) + const exportPipeRows = (() => { + const rows = analyticsApi + .toBoardStageRows(funnel, { includeRejected: true }) + .filter((r) => r.count > 0) + const total = rows.reduce((sum, r) => sum + r.count, 0) + return rows.map((r) => ({ + ...r, + pct: total ? Math.round((r.count / total) * 100) : 0, + })) + })() + const offers = asList(offersRes?.data) + const exportOfferCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0])) + for (const o of offers) { + if (o.status in exportOfferCounts) exportOfferCounts[o.status] += 1 + } + const counts = countsRes && typeof countsRes === 'object' ? countsRes : {} + const exportAttention = [ + { title: 'Unread applications', count: counts.unread }, + { title: 'Not assigned to a job', count: counts.unassigned }, + { title: 'Flagged duplicates', count: counts.duplicates }, + ] + const exportStarving = jobApps.filter((r) => r.requisition_status === 'open' && !r.count).length const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all' await exportStyledWorkbook({ filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`, - sheets: buildPeriodSheets({ + sheets: buildDashboardSheets({ department, - snapshots, + rangeKey, + span, exportedAt: new Date(), + kpis, + jobApps, + pipeRows: exportPipeRows, + offerCounts: exportOfferCounts, + attentionRows: exportAttention, + starvingCount: exportStarving, + activity: asList(activityRes?.data), }), }) toast('Dashboard exported to Excel', 'success') -- 2.40.1 From a077d55d3e7663dddf3a79a870f47faf431ce5fd Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 20:10:52 +0500 Subject: [PATCH 7/7] remove the s3 --- .gitea/workflows/ci.yml | 36 --------- .gitea/workflows/deploy-to-s3.yml | 79 +++++-------------- backend/job/candidate/views.py | 19 +---- .../src/components/ReapplicantHistory.jsx | 7 +- frontend/src/screens/CandidateProfile.jsx | 4 +- 5 files changed, 24 insertions(+), 121 deletions(-) delete mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 7db53b6..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: CI - -# Same checks deploy-to-s3.yml gates on, run before a change reaches main. -# main itself is excluded because the deploy workflow already runs them there; -# without branches-ignore every merge would run the suite twice. -on: - push: - branches-ignore: - - main - pull_request: - -jobs: - checks: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - pip install -r backend/requirements.txt - - - name: Run checks - run: bash scripts/ci-checks.sh diff --git a/.gitea/workflows/deploy-to-s3.yml b/.gitea/workflows/deploy-to-s3.yml index c5883f0..5e2b169 100644 --- a/.gitea/workflows/deploy-to-s3.yml +++ b/.gitea/workflows/deploy-to-s3.yml @@ -1,64 +1,25 @@ name: Deploy to S3 -# main only. Everything else is covered by ci.yml, which runs the same checks -# without deploying. on: push: - branches: + branches: - main jobs: - # Nothing was verified before this existed: a frontend that failed to compile - # would zip and ship exactly like a working one. `deploy` now needs this job, - # so a red main does not reach the bucket. - checks: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # 22 to match frontend/Dockerfile, so CI resolves the same tree the - # production image builds from. - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: '22' - - # 3.11 is the floor in pyproject.toml and the version the project's conda - # env runs. - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - pip install -r backend/requirements.txt - - - name: Run checks - run: bash scripts/ci-checks.sh - deploy: - needs: checks runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - # frontend/node_modules is excluded, and that is safe because of what - # happens to this object downstream. CodeDeploy pulls it, extracts to - # /opt/codedeploy-extracted-5, copies the tree to - # /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs - # `docker compose --env-file ./backend/.env up -d --build`. The only Node - # service is the frontend, whose image does `npm ci` from the lockfile, - # and frontend/.dockerignore excludes node_modules/ from the build context - # outright. So the committed tree was carried into every artifact and then - # thrown away unread. It was 90 MB of a 33 MB compressed upload. - # - # node_modules is still tracked in git, which is the reason it was here at - # all. Untracking it is a separate change and affects other branches. + - name: Configure AWS credentials + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + run: | + echo "AWS credentials configured" + - name: Archive project run: | apt-get update -y @@ -66,9 +27,8 @@ jobs: zip -r utopia-ai-hr-ats-portal.zip . \ -x ".git/*" \ -x ".gitea/*" \ - -x ".gitignore" \ - -x "frontend/node_modules/*" \ - -x "*.DS_Store" + -x ".gitignore/*" \ + -x "*.DS_Store" - name: Install AWS CLI run: | @@ -77,18 +37,19 @@ jobs: curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" unzip -q awscliv2.zip ./aws/install - aws --version + aws --version - # The credentials live only on this step. There used to be a separate - # "Configure AWS credentials" step above that set the same three variables - # and then only echoed a message — env: is scoped to its own step, so - # those values were discarded before anything could use them. It was doing - # nothing, and it read as though credentials were set up globally. - name: Upload files to S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1 run: | - echo "Uploading repo contents to S3..." - aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip + echo "Uploading repo contents to S3..." + aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip + + + + + + diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 390b001..d2ff7dd 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -1713,26 +1713,9 @@ class CandidateView: email=_norm_email(row.get("email")) if email not in packed: continue - packed[email]["applications"].append(row) + # ATS scores are job-assignment history, not applications. if "candidates" not in packed[email]["present_in"]: packed[email]["present_in"].append("candidates") - pipeline_jobs={} - for email,pack in packed.items(): - jobs=set() - for row in pack["applications"]: - if row.get("source") in ("inbox","manual") and row.get("job_post_id"): - jobs.add(row["job_post_id"]) - pipeline_jobs[email]=jobs - for email,pack in packed.items(): - jobs=pipeline_jobs.get(email) or set() - if not jobs: - continue - # ATS scores for a job the person already applied to are not a - # separate application — they duplicate the pipeline row. - pack["applications"]=[ - row for row in pack["applications"] - if not (row.get("source")=="ats" and row.get("job_post_id") in jobs) - ] for pack in packed.values(): pack["applications"].sort(key=lambda r: r.get("applied_at") or "",reverse=True) present=pack["present_in"] diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index a37d583..22a41fa 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -64,12 +64,7 @@ export function candidateApplicationsOf(row) { const self = syntheticCurrentApplication(row) if (self) items.push(self) } - items.sort((a, b) => { - const ac = isSameApplication(a, current) ? 1 : 0 - const bc = isSameApplication(b, current) ? 1 : 0 - if (ac !== bc) return bc - ac - return (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0) - }) + items.sort((a, b) => (appliedAtMs(b?.applied_at) ?? 0) - (appliedAtMs(a?.applied_at) ?? 0)) return items } diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 6c8357d..56dd373 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -19,7 +19,7 @@ import * as formsApi from '../api/forms' import * as pipelineApi from '../api/pipeline' import * as s3Api from '../api/s3' import CandidateFormsTab from './CandidateForms' -import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' +import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory' import { fmtDate, fmtTime, toDate } from '../lib/format' import { companies, moneyK, pick } from '../data/seed' @@ -361,7 +361,7 @@ export default function CandidateProfile({ {rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}
- +
{(live.match_summary || live.match_reasoning) && ( -- 2.40.1