From cc6c458df229aa7cd22627be8495ccc6b2287839 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 6 Aug 2026 13:38:53 +0500 Subject: [PATCH 1/2] File/CV TEXT Extraction --- backend/job/app.py | 13 ++++- backend/job/candidate/decorators.py | 74 ++++++++++++++++++++++++++++ backend/job/candidate/models.py | 0 backend/job/candidate/plugins.py | 29 +++++++++++ backend/job/candidate/serializers.py | 0 backend/job/candidate/views.py | 28 +++++++++++ backend/requirements.txt | 3 ++ 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 backend/job/candidate/decorators.py create mode 100644 backend/job/candidate/models.py create mode 100644 backend/job/candidate/plugins.py create mode 100644 backend/job/candidate/serializers.py create mode 100644 backend/job/candidate/views.py diff --git a/backend/job/app.py b/backend/job/app.py index 62e22cb..fe715c6 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -2,13 +2,18 @@ from fastapi import APIRouter,Depends from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session +from job.candidate.views import FileRead from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost +import logging from job.job_post.plugins import PlatformAlias +from fastapi import UploadFile, File from dotenv import load_dotenv load_dotenv() +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) router = APIRouter() @@ -51,11 +56,17 @@ async def get_job_alias(): @router.post("/candidate/cv_upload") async def cv_upload( + file: UploadFile = File(...), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), session: AsyncSession = Depends(get_session), ): try: - pass + file_content = await file.read() + logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)") + service=FileRead(session=session,filename=file.filename,file=file_content) + data=await service.read_file(file_content, file.filename) + + return JSONResponse(content={"data":data,"status_code":200}) except HTTPException: raise except Exception as e: diff --git a/backend/job/candidate/decorators.py b/backend/job/candidate/decorators.py new file mode 100644 index 0000000..9045226 --- /dev/null +++ b/backend/job/candidate/decorators.py @@ -0,0 +1,74 @@ +"""Text-cleanup decorators for `plugins.normalize_spaced_text`. + +Pure module: no FastAPI imports, no HTTPException, and no module-level state. +Each helper carries its own lookup tables and thresholds, so a caller can tune +one call site without moving a shared constant that every other caller reads. + +`normalize_unicode` and `despace_line` are stacked onto the formatter and run +outermost-first, so the text arrives already folded and already rebuilt: + + raw -> normalize_unicode -> despace_line -> normalize_spaced_text +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import wraps + + +def is_letter_spaced(line, *, min_tokens=4, single_char_ratio=0.6) -> bool: + """True when the line looks glyph-padded rather than normally typed. + + Both gates have to pass: a short line like "Next.js 3 A B" clears the ratio + on its own, so the token count is what keeps it out. + """ + tokens = line.split() + if len(tokens) < min_tokens: + return False + singles = sum(1 for token in tokens if len(token) == 1) + return singles / len(tokens) >= single_char_ratio + + +def normalize_unicode(func): + """Fold ligatures, drop invisibles, flatten every space variant to U+0020. + + Runs before the spacing heuristics so they only ever see one kind of gap. + """ + + @wraps(func) + def wrapper(text, *args, **kwargs): + text = text or "" + ligatures = {"ff": "ff", "fi": "fi", "fl": "fl", "ffi": "ffi", "ffl": "ffl"} + # Zero-width and soft-hyphen glyphs pypdf emits; they break word matching. + invisible = dict.fromkeys(map(ord, "​‌‍­"), None) + for ligature, plain in ligatures.items(): + text = text.replace(ligature, plain) + text = text.translate(invisible) + folded = "".join( + " " if char == "\t" or unicodedata.category(char) == "Zs" else char + for char in text + ) + return func(folded, *args, **kwargs) + + return wrapper + + +def despace_line(func): + """Rebuild every glyph-padded line: 2+ spaces are word gaps, single spaces are noise. + + Lines that fail `is_letter_spaced` are passed through untouched, because the + same rule applied to normally typed text would glue its words together. + """ + + @wraps(func) + def wrapper(text, *args, **kwargs): + lines = [] + for line in (text or "").splitlines(): + if is_letter_spaced(line): + words = re.split(r" {2,}", line.strip()) + line = " ".join(word.replace(" ", "") for word in words if word.strip()) + lines.append(line) + return func("\n".join(lines), *args, **kwargs) + + return wrapper diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py new file mode 100644 index 0000000..b0cb8ef --- /dev/null +++ b/backend/job/candidate/plugins.py @@ -0,0 +1,29 @@ +"""CV text cleanup helpers for the PDF extractor. + +Pure module: no FastAPI imports and no HTTPException. + +Designer-made resumes position every glyph individually, so pypdf hands back +"S K I L L S" instead of "SKILLS". In that layout a single space is glyph +padding and a run of two or more spaces is the real word gap, which is what +the `despace_line` decorator keys off to rebuild readable lines. +""" + +from __future__ import annotations + +import re + +from job.candidate.decorators import despace_line, normalize_unicode + + +@normalize_unicode +@despace_line +def normalize_spaced_text(text) -> str: + """Turn raw pypdf output into readable text, leaving normal lines untouched. + + The decorators have already folded the unicode and rebuilt the glyph-padded + lines; what is left is the whitespace tidy-up that every line wants. + """ + if not text: + return "" + lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()] + return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip() diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py new file mode 100644 index 0000000..a59ddd5 --- /dev/null +++ b/backend/job/candidate/views.py @@ -0,0 +1,28 @@ +from sqlalchemy.ext.asyncio import AsyncSession +import os,logging,io +from fastapi import HTTPException +from pypdf import PdfReader +from sqlalchemy import select +from job.candidate.plugins import normalize_spaced_text + +class FileRead: + def __init__(self,session:AsyncSession,filename=None,file=None): + self.session=session + self.filename=filename + self.file=file + + async def read_file(self,file=None,filename=None): + try: + reader = PdfReader(io.BytesIO(self.file)) + if reader.is_encrypted: + raise HTTPException(400, "PDF is password protected") + pages = [(page.extract_text() or "") for page in reader.pages] + return { + "filename": self.filename, + "num_pages": len(reader.pages), + "text": normalize_spaced_text("\n".join(pages)), + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, str(e)) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index bfb074d..0ef2d18 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -26,3 +26,6 @@ python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app. # --- other ----------------------------------------------------------------- httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py) bcrypt==5.0.0 # password hashing in users/plugins.py + +# --- PDF extraction -------------------------------------------------------- +pypdf==5.1.0 From 07a75e2ee63c40fd38b20539069b8b8c93d5cadd Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 6 Aug 2026 13:57:50 +0500 Subject: [PATCH 2/2] customScheduled --- backend/job/app.py | 41 ++++++++++------------------------- backend/job/job_post/views.py | 37 +++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/backend/job/app.py b/backend/job/app.py index fe715c6..af14011 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -4,13 +4,13 @@ from fastapi import HTTPException from db_setup import get_session from job.candidate.views import FileRead from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, model_validator from users.permissions import PermissionTag, require_permission -from job.job_post.views import JobPost +from job.job_post.views import JobPost,JobPostCreate import logging from job.job_post.plugins import PlatformAlias from fastapi import UploadFile, File from dotenv import load_dotenv +from datetime import datetime, time, timezone load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -18,30 +18,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -class JobPostCreate(BaseModel): - title: str - experience_min: int | None = None - experience_max: int | None = None - requirements: list[str] = [] - optional_skills: list[str] = [] - salary: str = "Anonymous" - location: str | None = None - employment_type: str | None = None - platform: str = "linkedin" - description: str | None = None - platform: str | None = None - channel_id: str | None = None - mode: str = "addToQueue" - due_at: str | None = None - - @model_validator(mode="after") - def validate_mode_and_due_at(self): - allowed = {"addToQueue", "shareNow", "customScheduled"} - if self.mode not in allowed: - raise ValueError(f"mode must be one of {sorted(allowed)}") - if self.mode == "customScheduled" and not self.due_at: - raise ValueError("due_at is required when mode is customScheduled") - return self @router.get("/jobs/alias") async def get_job_alias(): @@ -65,7 +41,7 @@ async def cv_upload( logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)") service=FileRead(session=session,filename=file.filename,file=file_content) data=await service.read_file(file_content, file.filename) - + return JSONResponse(content={"data":data,"status_code":200}) except HTTPException: raise @@ -81,8 +57,15 @@ async def post_job( ): try: service=JobPost(session=session) - data=await service.post_job(payload.model_dump(),current_user) - return JSONResponse(content={"data":data,"status_code":200}) + data=payload.model_dump() + if data['mode']=="customScheduled": + data['due_at']=datetime.combine( + data['scheduler_date'], + data['scheduler_time'] or time(0, 0, 0), + tzinfo=timezone.utc, + ).strftime("%Y-%m-%dT%H:%M:%S.000Z") + result=await service.post_job(data,current_user) + return JSONResponse(content={"data":result,"status_code":200}) except HTTPException: raise except Exception as e: diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index b80d985..3d64f93 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -1,10 +1,11 @@ +from datetime import date, time import os import httpx from dotenv import load_dotenv from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession - +from pydantic import BaseModel, model_validator from job.job_post.models import JobPosts from job.job_post.plugins import ( BufferError, @@ -21,6 +22,33 @@ from job.job_post.serializers import serialize_job_post load_dotenv() +class JobPostCreate(BaseModel): + title: str + experience_min: int | None = None + experience_max: int | None = None + requirements: list[str] = [] + optional_skills: list[str] = [] + salary: str = "Anonymous" + location: str | None = None + employment_type: str | None = None + platform: str = "linkedin" + description: str | None = None + platform: str | None = None + channel_id: str | None = None + mode: str = "addToQueue" + scheduler_time: time | None = time(0, 0, 0) + scheduler_date: date | None = None + due_at: str | None = None + + @model_validator(mode="after") + def validate_mode_and_due_at(self): + allowed = {"addToQueue", "shareNow", "customScheduled"} + if self.mode not in allowed: + raise ValueError(f"mode must be one of {sorted(allowed)}") + if self.mode == "customScheduled" and not self.due_at: + raise ValueError("due_at is required when mode is customScheduled") + return self + class JobPost: def __init__(self,session:AsyncSession): self.session=session @@ -84,15 +112,10 @@ class JobPost: due_at=payload.get("due_at"), ) except (httpx.HTTPError,BufferError,RuntimeError) as e: - # Include the reason: Buffer's rejections are actionable (duplicate text, - # daily limit, disconnected channel) and an opaque 502 sends the caller - # digging through job_posts.buffer_error to find out. + await JobPosts.mark_failed(self.session,str(row.id),str(e)) raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e - # Buffer accepting the mutation is not the same as the network publishing it: - # the default addToQueue mode returns `scheduled`, so the row only reads - # "published" once Buffer reports `sent`. saved=await JobPosts.mark_buffer_result( self.session, str(row.id),