From cc6c458df229aa7cd22627be8495ccc6b2287839 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 6 Aug 2026 13:38:53 +0500 Subject: [PATCH] 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