File/CV TEXT Extraction

pull/3/head
ahmed.mujtaba 2026-08-06 13:38:53 +05:00
parent cb7abf1aef
commit cc6c458df2
7 changed files with 146 additions and 1 deletions

View File

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

View File

@ -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", "": "fi", "": "fl", "": "ffi", "": "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

View File

View File

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

View File

View File

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

View File

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