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 (