diff --git a/.gitignore b/.gitignore index 1c8e1b3..115e33b 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ temp/ node_modules/ frontend/dist/ +# Uploaded content — user data, never in git +backend/uploads/ + **.pdf # Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # also swallowed any module with two underscores (e.g. test_talent_plugins.py). diff --git a/backend/job/app.py b/backend/job/app.py index c0330a7..672888a 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -275,6 +275,128 @@ async def cv_upload( raise HTTPException(status_code=500,detail=str(e)) +@router.post("/candidate/cv-bank/upload") +async def cv_bank_upload( + file: UploadFile = File(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + """Store a CV in the bank: the file plus its parsed text, nothing else. + No job, no user account, no inbox entry, no scoring — the CV waits until a + recruiter picks it up. Email/name are captured only if the CV contains + them. The PDF bytes go INTO the database (cv_bank_files), never onto the + container filesystem, so production redeploys cannot lose a stored CV.""" + from pathlib import PurePosixPath,PureWindowsPath + from job.candidate.models import Manual_UPLOAD_CANDIDATE + from job.candidate.plugins import extract_candidate_email + try: + content=await file.read() + if len(content)>15*1024*1024: + raise HTTPException(status_code=413,detail="CV must be under 15 MB") + reader=FileRead(session=session,filename=file.filename,file=content) + parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF + text=parsed.get("text") or "" + detected,_=extract_candidate_email(text) + # Basename against both separator styles — a Windows client sends + # C:\Users\x\cv.pdf whose PosixPath name is the whole string. + original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf" + row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv( + session, + candidate_email=detected or "", + candidate_name="", + full_text=text, + file_name=original, + created_by=current_user.get("id"), + pdf_bytes=content, + ) + return JSONResponse(content={"data":{ + "id":str(row.id), + "file_name":row.file_name, + "candidate_email":row.candidate_email or None, + "created_at":row.created_at.isoformat() if row.created_at else None, + },"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/candidate/cv-bank/fetch") +async def cv_bank_fetch( + top: int = Query(100, ge=1, le=500), + skip: int = Query(0, ge=0), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """The stored-CV bank, newest first. Download the file via + GET /documents/download?manual_upload_candidate_id=.""" + from job.candidate.models import Manual_UPLOAD_CANDIDATE + try: + rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip) + data=[{ + "id":str(r.id), + "file_name":r.file_name, + "candidate_email":r.candidate_email or None, + "candidate_name":r.candidate_name or None, + "created_at":r.created_at.isoformat() if r.created_at else None, + } for r in rows] + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/candidate/cv-bank/file") +async def cv_bank_file( + id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """The stored CV's bytes, straight from the database. Content-Disposition + carries the original filename so browser saves are named sensibly; the + frontend preview re-types the blob and renders it inline.""" + from urllib.parse import quote + from job.candidate.models import CvBankFiles,Manual_UPLOAD_CANDIDATE + try: + row=await Manual_UPLOAD_CANDIDATE.get_by_id(session,id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found in the bank") + file_row=await CvBankFiles.get(session,row.id) + if not file_row: + raise HTTPException(status_code=404,detail="CV file is missing") + name=file_row.file_name or row.file_name or "cv.pdf" + return Response( + content=file_row.data, + media_type=file_row.content_type or "application/pdf", + headers={"Content-Disposition":f"attachment; filename*=UTF-8''{quote(name)}"}, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.delete("/candidate/cv-bank/delete") +async def cv_bank_delete( + id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)), + session: AsyncSession = Depends(get_session), +): + from job.candidate.models import Manual_UPLOAD_CANDIDATE + try: + row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id) + if not row: + raise HTTPException(status_code=404,detail="CV not found in the bank") + # Legacy rows from before bytes moved into the DB still carry a disk file. + FileRead.discard_upload(row.file_path) + return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.post("/candidate/inbox-match") async def candidate_inbox_match( inbox_message_id: str = Query(...), @@ -314,6 +436,54 @@ async def post_job( raise HTTPException(status_code=500,detail=str(e)) +@router.post("/job/image/upload") +async def upload_job_image( + job_post_id: str = Form(...), + file: UploadFile = File(...), + current_user: dict = Depends(require_permission( + PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False, + )), + session: AsyncSession = Depends(get_session), +): + """Attach (or replace) the cover image of a job post. Stored in the + job_post_images table; the create flow calls this right after /job/post-job.""" + try: + content=await file.read() + service=JobPost(session=session) + data=await service.save_job_image( + job_post_id,file.filename,file.content_type,content,current_user, + ) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/job/image/fetch") +async def fetch_job_image( + job_post_id: str = Query(...), + current_user: dict = Depends(require_permission( + PermissionTag.JOBS_VIEW, PermissionTag.JOB_BOARD_VIEW, require_all=False, + )), + session: AsyncSession = Depends(get_session), +): + """The stored cover image, served inline from the database; 404 when the + post has none.""" + try: + service=JobPost(session=session) + content,media_type=await service.get_job_image(job_post_id) + return Response( + content=content, + media_type=media_type, + headers={"Content-Disposition":"inline"}, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + class JobAssistRequest(BaseModel): field: Literal[ "title", "department", "location", "salary", diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index da167ba..63c0966 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -372,6 +372,132 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): out[key] = first return out + # ---- CV bank ----------------------------------------------------------- + # apply_via="cv_bank" rows are a private store of CVs with NO job, NO user + # account and NO inbox entry — deliberately invisible to Candidates, + # Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait + # until a recruiter picks them up; email is captured only when the CV + # contains one. + + @classmethod + async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email, + candidate_name, full_text, file_name, + created_by, pdf_bytes, + content_type="application/pdf"): + """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit — + the PDF lives in the database, never on the container filesystem, so a + redeploy cannot lose a stored CV. file_path stays "" by design. + + When the CV carries an email, the candidate ACCOUNT is created/reused + (same pattern as create_manual_upload_candidate) so the person shows + up on the Candidates screen; unlike an application there is still no + inbox entry, no scoring, and no setup email. A CV with no detectable + email banks fine and simply stays account-less.""" + import os + + from role.models import EnumRoles, Roles + from users.models import Users + from users.plugins import hash_password + + email = (candidate_email or "").strip().lower() + user = None + if email: + user = await Users.get_user_by_email(session, email) + if not user: + role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value) + user = await Users.insert_user(session, { + "name": (candidate_name or "").strip() or email, + "email": email, + "role_id": role.id if role else 8, + "password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")), + "is_active": True, + "is_deleted": False, + }) + elif user.is_deleted or not user.is_active: + # get_user_by_email returns soft-deleted accounts too; a fresh + # bank upload means the person is relevant again. + user.is_deleted = False + user.is_active = True + session.add(user) + + row = cls( + candidate_email=email, + candidate_name=(candidate_name or "").strip() or (email or ""), + job_post_id=None, + full_text=full_text or "", + linkedin_slug=primary_slug_from_text(full_text or ""), + apply_via="cv_bank", + user_id=user.id if user else None, + created_by=cls._as_uuid(created_by), + status="BANKED", + file_name=(file_name or "").strip(), + file_path="", + ) + session.add(row) + await session.flush() + session.add(CvBankFiles( + manual_upload_candidate_id=row.id, + content_type=content_type, + file_name=(file_name or "").strip(), + data=pdf_bytes, + )) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def list_bank(cls, session: AsyncSession, limit=100, offset=0): + total = ( + await session.execute( + select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank") + ) + ).scalar() or 0 + result = await session.execute( + select(cls) + .where(cls.apply_via == "cv_bank") + .order_by(cls.created_at.desc(), cls.id.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @classmethod + async def delete_bank_cv(cls, session: AsyncSession, record_id): + """Hard delete, bank rows only — never reachable for application rows. + The cv_bank_files row goes with it via ON DELETE CASCADE.""" + row = await cls.get_by_id(session, record_id) + if not row or row.apply_via != "cv_bank": + return None + file_row = await CvBankFiles.get(session, row.id) + if file_row: + await session.delete(file_row) + await session.delete(row) + await session.commit() + return row + + +class CvBankFiles(SQLModel, table=True): + """PDF bytes of a CV-bank entry — in the database so production redeploys + (ephemeral container filesystems) can never lose a stored CV. Created in + prod by migrations/manual/010_cv_bank_files.sql.""" + + __tablename__ = "cv_bank_files" + + manual_upload_candidate_id: uuid.UUID = Field( + primary_key=True, foreign_key="manual_upload_candidate.id", + ) + content_type: str = Field(default="application/pdf") + file_name: str | None = Field(default=None) + data: bytes + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get(cls, session: AsyncSession, manual_upload_candidate_id): + result = await session.execute( + select(cls).where(cls.manual_upload_candidate_id == manual_upload_candidate_id) + ) + return result.scalars().first() + class Candidates(SQLModel, table=True): diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 9e47f41..db4031b 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -195,11 +195,18 @@ class FileRead: await self.session.commit() created_at=datetime.now(timezone.utc).isoformat() - task=await match_uploaded_cv.kicker().with_labels( - created_at=created_at, - correlation_id=str(row.id), - queue=CV_QUEUE_NAME, - ).kiq(str(row.id),force=False) + # Enqueue failure must not fail the upload: the CV row is already + # persisted, and with the broker down (optional locally) the kiq call + # raises a connection error. Suggestions just arrive later, or never. + try: + task=await match_uploaded_cv.kicker().with_labels( + created_at=created_at, + correlation_id=str(row.id), + queue=CV_QUEUE_NAME, + ).kiq(str(row.id),force=False) + except Exception as e: + logger.warning("cv match enqueue skipped for %s: %s",row.id,e) + task=None account_setup=None if new_user_email: @@ -229,7 +236,7 @@ class FileRead: return { "queued":True, "inbox_message_id":str(row.id), - "task_id":task.task_id, + "task_id":task.task_id if task else None, "filename":parsed.get("filename"), "num_pages":parsed.get("num_pages"), "candidate_email":email, diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 36f733c..78e35aa 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -262,6 +262,50 @@ class JobPosts(SQLModel, table=True): return await cls.get_job_post_by_id(session, record_id) +class JobPostImages(SQLModel, table=True): + """Cover image of a job post, stored as bytes IN the database. + + Deliberately not on disk: production containers have ephemeral filesystems, + so a file-backed image dies on every redeploy. One row per post — the PK is + the job_posts FK, which makes re-upload a plain replace. Created in prod by + migrations/manual/009_job_post_images.sql (autogen is off there).""" + + __tablename__ = "job_post_images" + + job_post_id: uuid.UUID = Field(primary_key=True, foreign_key="job_posts.id") + content_type: str + file_name: str | None = Field(default=None) + data: bytes + uploaded_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get(cls, session: AsyncSession, job_post_id: uuid.UUID): + result = await session.execute(select(cls).where(cls.job_post_id == job_post_id)) + return result.scalars().first() + + @classmethod + async def upsert(cls, session: AsyncSession, job_post_id: uuid.UUID, *, + content_type: str, file_name: str | None, data: bytes, + uploaded_by: uuid.UUID | None): + row = await cls.get(session, job_post_id) + if row: + row.content_type = content_type + row.file_name = file_name + row.data = data + row.uploaded_by = uploaded_by + row.updated_at = _now() + else: + row = cls( + job_post_id=job_post_id, content_type=content_type, + file_name=file_name, data=data, uploaded_by=uploaded_by, + ) + session.add(row) + await session.commit() + return row + + class SocialPlatform(SQLModel, table=True): """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index bae385c..8604722 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -100,7 +100,6 @@ def render_job_post(payload) -> str: experience_max = payload.get("experience_max") requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()] optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()] - salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous" description = (payload.get("description") or "").strip() lines = [f"We're hiring: {title}", ""] @@ -136,9 +135,6 @@ def render_job_post(payload) -> str: lines.append(f"• {item}") lines.append("") - lines.append(f"Salary: {salary}") - lines.append("") - if description: lines.append(description) lines.append("") diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index f11ac97..c42af25 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -2,6 +2,7 @@ from datetime import date, time import logging import os import uuid +from pathlib import Path import httpx from dotenv import load_dotenv @@ -9,7 +10,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages -from job.job_post.models import JobPosts,SocialPlatform +from job.job_post.models import JobPostImages,JobPosts,SocialPlatform from users.models import Users from job.job_post.plugins import ( BufferError, @@ -26,6 +27,20 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() logger=logging.getLogger("job.job_post") +# Cover images live in the job_post_images table (bytea), NOT on disk: +# production containers have ephemeral filesystems, so a file-backed image +# would vanish on every redeploy. One row per post; re-upload replaces it. +ALLOWED_IMAGE_TYPES={"image/png","image/jpeg","image/webp","image/gif"} +IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"} +MAX_JOB_IMAGE_BYTES=5*1024*1024 + + +def _job_image_key(job_post_id) -> uuid.UUID: + try: + return uuid.UUID(str(job_post_id)) + except ValueError as e: + raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e + class JobPostCreate(BaseModel): title: str @@ -226,6 +241,43 @@ class JobPost: raise HTTPException(status_code=404,detail="Job post not found") return {"id":str(row.id),"deleted":True} + async def save_job_image(self,job_post_id,filename,content_type,content,current_user): + if not current_user: + raise HTTPException(status_code=401,detail="Not authenticated") + key=_job_image_key(job_post_id) + media=(content_type or "").lower() + if media not in ALLOWED_IMAGE_TYPES: + # Fall back to the filename extension; browsers occasionally send + # application/octet-stream for perfectly valid images. + suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower() + media=IMAGE_TYPE_BY_EXT.get(suffix) + if not media: + raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF") + if not content: + raise HTTPException(status_code=400,detail="Empty image upload") + if len(content)>MAX_JOB_IMAGE_BYTES: + raise HTTPException(status_code=413,detail="Image must be under 5 MB") + rows,total=await JobPosts.fetch_job_posts(self.session,ids=[str(key)],active_only=False) + if not total: + raise HTTPException(status_code=404,detail="Job post not found") + raw_user=(current_user or {}).get("id") + uploaded_by=uuid.UUID(str(raw_user)) if raw_user else None + await JobPostImages.upsert( + self.session,key, + content_type=media, + file_name=Path((filename or "").replace("\\","/")).name or None, + data=content, + uploaded_by=uploaded_by, + ) + return {"job_post_id":str(key),"has_image":True} + + async def get_job_image(self,job_post_id): + key=_job_image_key(job_post_id) + row=await JobPostImages.get(self.session,key) + if not row: + raise HTTPException(status_code=404,detail="No image for this job post") + return row.data,row.content_type + async def set_job_status(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") diff --git a/backend/migrations/manual/009_job_post_images.sql b/backend/migrations/manual/009_job_post_images.sql new file mode 100644 index 0000000..36f4d8a --- /dev/null +++ b/backend/migrations/manual/009_job_post_images.sql @@ -0,0 +1,21 @@ +-- 009_job_post_images.sql +-- Cover images of job posts, stored IN the database (bytea) rather than on the +-- container filesystem, which is ephemeral in production — a disk-backed image +-- would vanish on every redeploy. One row per post: the PK doubles as the FK, +-- so a re-upload is a plain replace. 5 MB cap and type checks are enforced by +-- the API layer (backend/job/job_post/views.py save_job_image). +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations. Matches the SQLModel JobPostImages model in +-- backend/job/job_post/models.py (needed here because prod boots with +-- DB_AUTOGENERATE=false and never autogenerates new tables). + +CREATE TABLE IF NOT EXISTS app.job_post_images ( + job_post_id uuid PRIMARY KEY REFERENCES app.job_posts(id) ON DELETE CASCADE, + content_type varchar NOT NULL, + file_name varchar, + data bytea NOT NULL, + uploaded_by uuid REFERENCES app.users(id), + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); diff --git a/backend/migrations/manual/010_cv_bank_files.sql b/backend/migrations/manual/010_cv_bank_files.sql new file mode 100644 index 0000000..ae09070 --- /dev/null +++ b/backend/migrations/manual/010_cv_bank_files.sql @@ -0,0 +1,21 @@ +-- 010_cv_bank_files.sql +-- PDF bytes of CV-bank entries, stored IN the database. The bank's metadata +-- row lives in app.manual_upload_candidate (apply_via = 'cv_bank'); keeping +-- the file itself on the container filesystem would lose every stored CV on +-- redeploy, so bank uploads write the bytes here instead and no disk file is +-- created at all. PK doubles as the FK: one file per bank row, removed +-- automatically with it. +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations (prod boots with DB_AUTOGENERATE=false and +-- never autogenerates tables). Matches CvBankFiles in +-- backend/job/candidate/models.py. + +CREATE TABLE IF NOT EXISTS app.cv_bank_files ( + manual_upload_candidate_id uuid PRIMARY KEY + REFERENCES app.manual_upload_candidate(id) ON DELETE CASCADE, + content_type varchar NOT NULL DEFAULT 'application/pdf', + file_name varchar, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW() +); diff --git a/backend/talent/plugins.py b/backend/talent/plugins.py index 2bca488..a0409fc 100644 --- a/backend/talent/plugins.py +++ b/backend/talent/plugins.py @@ -418,11 +418,17 @@ def _match_tokens(*texts) -> set[str]: def relevance_score(job: dict, profile: dict) -> int: """0-100 job-fit rank for sorting, computed when a profile is persisted. - Deterministic and free. Title component: the job title as an exact PHRASE - in the person's current title scores 55, in their headline 45; scattered + Deterministic and free. Title component: a current title CONTAINING every + job-title token scores 55 — containment, not exact phrase, because job + titles rarely reappear verbatim ("Generative Engineer" vs the pool's + "Generative AI Engineer"; seen live: the phrase rule dropped every real + match to the scattered tier and compressed the whole pool into the 40s). + The job title as an exact phrase in the headline scores 45; scattered token overlap caps at 35 — a keyword-stuffed headline ("AI/ML Engineer | Python | FastAPI | ...") must not outrank someone whose title IS the job - title, which is exactly what token overlap alone did on live data. + title, which is exactly what token overlap alone did on live data. The + headline tier stays phrase-only for the same reason: stuffed headlines + contain every token of every hot title. Skills component (up to 45): GRADED token overlap between the content words of the job's requirements + optional skills and the person's @@ -432,16 +438,20 @@ def relevance_score(job: dict, profile: dict) -> int: whole live pool on exactly 60. """ job_title = _clean_phrase(job.get("title")) + job_title_tokens = set(job_title.split()) title_text = _clean_phrase(profile.get("current_title")) headline_text = _clean_phrase(profile.get("headline")) - if job_title and job_title in title_text: + if job_title and job_title_tokens <= set(title_text.split()): title_component = 55.0 elif job_title and job_title in headline_text: title_component = 45.0 else: - title_tokens = set(job_title.split()) role_tokens = set(title_text.split()) | set(headline_text.split()) - ratio = len(title_tokens & role_tokens) / len(title_tokens) if title_tokens else 0.0 + ratio = ( + len(job_title_tokens & role_tokens) / len(job_title_tokens) + if job_title_tokens + else 0.0 + ) title_component = 35 * ratio job_tokens = _match_tokens( diff --git a/backend/tests/test_talent_plugins.py b/backend/tests/test_talent_plugins.py index 4aeef2a..e16c6d1 100644 --- a/backend/tests/test_talent_plugins.py +++ b/backend/tests/test_talent_plugins.py @@ -404,7 +404,25 @@ def test_headline_phrase_scores_below_title_phrase(): scattered = plugins.relevance_score(job, {"current_title": "Engineer", "headline": "Agentic AI | Python"}) assert in_title == 55 assert in_headline == 45 - assert scattered == 35 # both tokens present but never as the phrase + assert scattered == 35 # tokens split across title and headline never combine + + +def test_title_containment_scores_like_an_exact_title(): + # Live case: job "Generative Engineer", pool titled "Generative AI + # Engineer" — the exact phrase never occurs, so every genuine match fell + # to the scattered 35 tier and the whole pool compressed into the 40s. + job = {"title": "Generative Engineer", "requirements": [], "optional_skills": []} + interleaved = plugins.relevance_score(job, {"current_title": "Generative AI Engineer"}) + senior = plugins.relevance_score(job, {"current_title": "Senior Generative AI Engineer"}) + assert interleaved == 55 + assert senior == 55 + # Containment applies to the TITLE only: the same tokens scattered across + # a keyword-stuffed headline still cap at the 35 tier. + stuffed = plugins.relevance_score( + job, + {"current_title": "Developer", "headline": "Generative AI | Engineer | Python"}, + ) + assert stuffed == 35 # ---------------------------------------------------------------- detail extraction diff --git a/frontend/dist/index.html b/frontend/dist/index.html index df37875..d07a381 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -16,15 +16,16 @@ - + - + - - + +
diff --git a/frontend/index.html b/frontend/index.html index f425b9e..7ce4819 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,12 +16,13 @@ - + - + diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 5f5ca5f..bb997cb 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -12,7 +12,7 @@ function returns the parsed {data, total, status_code} envelope. ============================================================ */ -import { downloadFile, request } from '../lib/apiClient' +import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' /** Active job posts for pickers. Needs job_board.view OR candidates.view. * @@ -53,6 +53,45 @@ export function scoreUploads(jobId, files) { return request('/candidate/score', { method: 'POST', body: form }) } +/** + * CV bank — a private store of CVs with NO job, NO user account and NO inbox + * entry (POST /candidate/cv-bank/upload). Nothing is scored; the file just + * waits until a recruiter picks it up. Email is captured only when the CV + * contains one. One file per request. Needs candidates.create. + */ +export function uploadToCvBank(file) { + const form = new FormData() + form.append('file', file, file.name) + return request('/candidate/cv-bank/upload', { method: 'POST', body: form }) +} + +/** The stored-CV bank, newest first — GET /candidate/cv-bank/fetch. */ +export function listCvBank({ top = 100, skip = 0 } = {}) { + return request('/candidate/cv-bank/fetch', { params: { top, skip } }) +} + +/** Permanently remove a stored CV (file included). Needs candidates.delete. */ +export function deleteCvBankCv(id) { + return request('/candidate/cv-bank/delete', { method: 'DELETE', params: { id } }) +} + +/** Browser-save a stored CV's PDF — served from the database (cv_bank_files). */ +export function downloadCvBankCv(id) { + return downloadFile('/candidate/cv-bank/file', { params: { id } }) +} + +/** + * Object URL of a stored CV for IN-APP preview (no download). The route sends + * an attachment disposition, so the blob is re-typed to application/pdf for + * the browser's inline viewer. Caller revokes the URL when the preview closes. + */ +export function viewCvBankCv(id) { + return fetchBlobUrl('/candidate/cv-bank/file', { + params: { id }, + type: 'application/pdf', + }) +} + /** * Score the decoded attachments of inbox messages against a job post. * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index c8db05b..4a268ea 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -1,4 +1,4 @@ -import { downloadFile, request } from '../lib/apiClient' +import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' /** * Job requisitions — backend/job/app.py `GET /jobs/fetch`. @@ -57,7 +57,6 @@ export function toJobView(row) { experienceMin: row.experience_min, experienceMax: row.experience_max, experience: experienceLabel(row.experience_min, row.experience_max), - salary: row.salary, skills: row.requirements ?? [], optionalSkills: row.optional_skills ?? [], description: row.description, @@ -97,6 +96,19 @@ export function remove(jobPostId) { }) } +/** Attach or replace a job post's cover image — POST /job/image/upload (multipart). */ +export function uploadImage(jobPostId, file) { + const fd = new FormData() + fd.append('job_post_id', jobPostId) + fd.append('file', file) + return request('/job/image/upload', { method: 'POST', body: fd }) +} + +/** Object URL of the cover image, or null when the post has none. Caller revokes. */ +export function fetchImageUrl(jobPostId) { + return fetchBlobUrl('/job/image/fetch', { params: { job_post_id: jobPostId } }) +} + /** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */ export function setStatus(jobPostId, status) { const requisition_status = LABEL_TO_STATUS[status] ?? status diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index de62040..13ba098 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -7,6 +7,7 @@ import AiDock from './AiDock' import { ROUTE_BY_PATH } from './routes' import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell' import Icon from '../ui/icons' +import ErrorBoundary from '../components/ErrorBoundary' import Spinner from '../components/Spinner' export default function AppLayout() { @@ -38,6 +39,7 @@ export default function AppLayout() { return (
+ Skip to content setNavOpen((o) => !o)} searchRef={searchRef} />
-
}> - - + {/* Keyed by pathname: navigating away from a crashed screen resets it. */} + + }> + + + diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx index b79370a..7aede97 100644 --- a/frontend/src/app/Sidebar.jsx +++ b/frontend/src/app/Sidebar.jsx @@ -34,7 +34,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge -