. #79

Merged
ahmed.mujtaba merged 8 commits from SQS_BROKER into main 2026-09-07 15:12:38 +00:00
30 changed files with 1052 additions and 350 deletions

View File

@ -2,48 +2,24 @@ name: Deploy to S3
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'
- 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
@ -51,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: |
@ -62,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

View File

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

View File

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

View File

@ -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/<name> 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.
"""

View File

@ -203,15 +203,13 @@ 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),
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.
limit: int | None = Query(None,ge=1,le=500),
current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session),
@ -222,7 +220,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 +241,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 +250,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:

View File

@ -126,10 +126,23 @@ 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,
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:
@ -159,8 +172,19 @@ 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()
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 +370,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 +379,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 +467,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 +485,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 +511,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 +541,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(

View File

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

View File

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

View File

@ -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
@ -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,
@ -1031,6 +1043,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.
@ -1060,7 +1073,26 @@ 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()
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 +1108,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 +1194,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 +1315,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 +1325,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 +1871,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."""

View File

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

View File

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

View File

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

View File

@ -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,
@ -256,31 +256,16 @@ 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
return await cv.attach_application_history(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):
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)
@ -288,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:
@ -304,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
@ -323,6 +309,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,20 +504,31 @@ 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."""
"""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."""
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)
@ -550,7 +587,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 +599,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)

View File

@ -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"]
@ -1751,7 +1734,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 +1748,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,26 +48,57 @@ 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) => (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 +183,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 +192,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 (
<div
className="card"
@ -188,13 +228,14 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
marginBottom: 10,
}}
>
{title}
{heading}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 +245,12 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
item.job_post_id,
idx,
].filter(Boolean).join(':')
const jobLabel = (
<>
{job}
{isCurrent ? ' (Current)' : ''}
</>
)
return (
<div
key={key}
@ -215,13 +262,13 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
<Link
to={href}
className="reapplicant-job-link"
title="Open this previous application"
title={isCurrent ? 'This application' : 'Open this application'}
onClick={(e) => e.stopPropagation()}
>
{job}
{jobLabel}
</Link>
) : (
<div className="fw-600" style={{ fontSize: 13 }}>{job}</div>
<div className="fw-600" style={{ fontSize: 13 }}>{jobLabel}</div>
)}
<div className="cell-sub">
{SOURCE_LABEL[item.source] || item.source || 'Application'}

View File

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

View File

@ -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({
<span className="cell-sub">{rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}</span>
</div>
</div>
<Info label="Applications" val={live.job_posts?.length || 0} />
<Info label="Applications" val={candidateApplicationsOf(live).length} />
</div>
{(live.match_summary || live.match_reasoning) && (

View File

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

View File

@ -68,11 +68,65 @@ 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 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, at: Date.now() })
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: OPENED_DETAIL_TTL_MS,
gcTime: OPENED_DETAIL_TTL_MS,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
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 +308,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 +563,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 +608,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 +621,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,21 +965,24 @@ 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
* 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, 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 (
<div className="inbox-filters">
<button
@ -925,9 +1001,9 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
{open && (
<div className="inbox-filter-stack">
<div className="form-field">
<label htmlFor="inbox-f-location">Location</label>
<label htmlFor="inbox-f-city">City</label>
<select
id="inbox-f-location"
id="inbox-f-city"
value={filters.location}
onChange={(e) => onChange('location', e.target.value)}
>
@ -940,6 +1016,34 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
))}
</select>
</div>
<div className="form-field">
<label htmlFor="inbox-f-source">Source / Platform</label>
<select
id="inbox-f-source"
value={filters.source || ''}
onChange={(e) => onChange('source', e.target.value)}
>
<option value="">Any</option>
{filters.source && !platforms.includes(filters.source) && (
<option value={filters.source}>{filters.source}</option>
)}
{platforms.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="form-field">
<label htmlFor="inbox-f-assigned">Assigned job</label>
<select
id="inbox-f-assigned"
value={filters.assigned || ''}
onChange={(e) => onChange('assigned', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Assigned</option>
<option value="no">Unassigned</option>
</select>
</div>
{showLinkFilters && (
<>
<div className="form-field">
@ -1060,6 +1164,12 @@ export default function Inbox() {
setSelectedId(null)
}, [])
const city = inboxFilters.location
const source = inboxFilters.source
const assignedParams = inboxFilters.assigned === 'yes'
? { assigned: true }
: inboxFilters.assigned === 'no'
? { assigned: false }
: {}
const deepOpen = searchParams.get('open')
const deepKind = searchParams.get('kind')
@ -1085,6 +1195,8 @@ export default function Inbox() {
const pageLimit = pageSize === 'all' ? undefined : pageSize
const activeInboxFilters = [
inboxFilters.location,
inboxFilters.source,
inboxFilters.assigned,
...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []),
].filter(Boolean).length
@ -1097,7 +1209,9 @@ export default function Inbox() {
skip: pageLimit == null ? 0 : skip,
...(search ? { search } : {}),
...(city ? { city } : {}),
}), [tabFilter, skip, pageLimit, search, city])
...(source ? { source } : {}),
...assignedParams,
}), [tabFilter, skip, pageLimit, search, city, source, assignedParams])
/**
* Sheet Forms only. On the All channel these rows are merged with email ones,
@ -1119,18 +1233,28 @@ export default function Inbox() {
...formTabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters,
}), [formSheet, skip, pageLimit, search, city, formTabFilter, isAllChannel, linkFilters])
}), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters])
const citiesQuery = useQuery({
queryKey: qk.mailbox.cities(),
queryFn: async () => {
const res = await inboxApi.listApplications({ cityList: true, top: 1 })
return Array.isArray(res?.cities) ? res.cities : []
return {
cities: Array.isArray(res?.cities) ? res.cities : [],
sources: Array.isArray(res?.sources) ? res.sources : [],
}
},
staleTime: 5 * 60 * 1000,
})
const cities = citiesQuery.data ?? []
const cities = Array.isArray(citiesQuery.data)
? citiesQuery.data
: (citiesQuery.data?.cities ?? [])
const sources = Array.isArray(citiesQuery.data)
? []
: (citiesQuery.data?.sources ?? [])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -1172,8 +1296,10 @@ export default function Inbox() {
sheet: formCountsSheet,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters,
}), [formCountsSheet, search, city, linkFilters])
}), [formCountsSheet, search, city, source, assignedParams, linkFilters])
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts(formCountsParams),
queryFn: async () => {
@ -1288,7 +1414,7 @@ export default function Inbox() {
const searchTotal = isAllChannel
? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0))
: (activeQuery.data?.total ?? 0)
const total = (search || city)
const total = (search || city || source || inboxFilters.assigned)
? searchTotal
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
@ -1316,7 +1442,16 @@ 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(() => {
pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null)
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
const sidebar = useMemo(() => {
if (!isForms) return list
@ -1336,6 +1471,7 @@ export default function Inbox() {
...selectedRow,
...(detailQuery.data ?? {}),
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
}
: null
@ -1385,7 +1521,12 @@ export default function Inbox() {
setQ('')
setSearch('') // clear the committed term too, or the new channel's first
// fetch carries the old channel's search for 300ms
setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location }))
setInboxFilters((f) => ({
...EMPTY_INBOX_FILTERS,
location: f.location,
source: f.source,
assigned: f.assigned,
}))
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear()
@ -1407,7 +1548,13 @@ export default function Inbox() {
if (SERVER_SCOPED_TABS.has(tab)) {
setReadAll.mutate({
read,
filter: { ...tabFilter, ...(search ? { search } : {}) },
filter: {
...tabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
},
// sheet rows carry no mailbox read state email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
})
@ -1701,6 +1848,7 @@ export default function Inbox() {
<InboxFilters
filters={inboxFilters}
cities={cities}
sources={sources}
showLinkFilters={isForms}
active={activeInboxFilters}
open={showInboxFilters}
@ -1874,32 +2022,40 @@ export default function Inbox() {
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : selected?.kind === 'form' ? (
<FormApplicantDetail
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
) : (
<ApplicationDetail
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
<div
className={detailLocked ? 'inbox-detail-pending' : undefined}
inert={detailLocked || undefined}
aria-busy={detailLocked || undefined}
>
{selected?.kind === 'form' ? (
<FormApplicantDetail
item={selected}
loading={detailLocked}
busy={detailLocked || setState.isPending || markDuplicate.isPending}
canEdit={!detailLocked && canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
) : (
<ApplicationDetail
item={selected}
loading={detailLocked}
busy={detailLocked || setState.isPending || markDuplicate.isPending}
canEdit={!detailLocked && canEdit}
toast={toast}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)}
/>
)}
</div>
)}
</div>
</div>
@ -2243,6 +2399,7 @@ function FormApplicantDetail({
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
lazy
/>
))}
{manualPost && (
@ -2252,6 +2409,7 @@ function FormApplicantDetail({
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
lazy
/>
)}
</>
@ -2467,7 +2625,7 @@ function ApplicationDetail({
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{Math.round(i.atsScore)}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
@ -2650,6 +2808,7 @@ function ApplicationDetail({
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
lazy
/>
))
)}
@ -2661,6 +2820,7 @@ function ApplicationDetail({
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
lazy
/>
)}
<button

View File

@ -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). */

View File

@ -30,25 +30,48 @@ function reqLabel(req) {
return String(req)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge, lazy = false }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
const [open, setOpen] = useState(false)
const hasBody = Boolean(
post?.location || post?.employment_type || post?.description
|| (Array.isArray(post?.requirements) && post.requirements.length),
)
const detailQuery = useQuery({
queryKey: qk.jobPosts.detail(post?.id),
queryFn: async () => {
const res = await jobPostsApi.list({ ids: [post.id], activeOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows[0] || null
},
enabled: Boolean(lazy && open && post?.id && !unavailable && !hasBody),
staleTime: 10 * 60_000,
gcTime: 60 * 60_000,
})
const body = (!lazy || hasBody) ? post : (detailQuery.data || post)
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
body?.employment_type,
body?.location,
body?.experience_min != null || body?.experience_max != null
? `${body?.experience_min ?? '?'}${body?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
const showBody = !lazy || open
return (
<div
role="radio"
aria-checked={selected}
aria-expanded={lazy ? open : undefined}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onClick={() => {
if (unavailable) return
onSelect(post.id)
if (lazy) setOpen(true)
}}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
@ -67,6 +90,20 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
{lazy && !unavailable && (
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label={open ? 'Hide role details' : 'Show role details'}
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
style={{ padding: 4, minWidth: 28 }}
>
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
</button>
)}
<span className="tag">{tag}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
{showBody && detailQuery.isPending && lazy && !hasBody && (
<div className="cell-sub">Loading role</div>
)}
{showBody && detailQuery.isError && lazy && (
<div className="cell-sub">{friendlyAuthError(detailQuery.error, 'Could not load this role.')}</div>
)}
{showBody && meta && <div className="cell-sub">{meta}</div>}
{showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{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)