Spaces:
Configuration error
Configuration error
File size: 11,490 Bytes
3d25a86 bc9d0f6 3d25a86 ca3a272 3d25a86 551722c 3d25a86 551722c 3d25a86 551722c 3d25a86 551722c 3d25a86 551722c 3d25a86 bc9d0f6 3d25a86 551722c 3d25a86 551722c 3d25a86 ca3a272 551722c ca3a272 551722c ca3a272 551722c ca3a272 3d25a86 bc9d0f6 3d25a86 551722c 3d25a86 bc9d0f6 3d25a86 ca3a272 3d25a86 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | """Deterministic document parsing.
This is not an LLM agent and must not become one. PDF/DOCX extraction is a
solved library problem with exact, reproducible character offsets; a model would
paraphrase rather than transcribe, destroying the `(page, start_char, end_char)`
anchoring that the evidence-citation layer depends on.
Failure is loud. A resume is parsed once and reused across every job, so a
guessed or partial parse would silently bias every future score for that
candidate — better to raise and let the document be re-uploaded or OCR'd.
"""
from __future__ import annotations
import io
import zipfile
from dataclasses import dataclass, field
from typing import Literal
import fitz
from docx import Document as DocxDocument
from docx.opc.exceptions import PackageNotFoundError
from app.config import DOCX_MIME
from app.exceptions import (
DocumentParseError,
EmptyDocumentError,
UnsupportedMediaTypeError,
)
from app.logging import get_logger
logger = get_logger(__name__)
PARSER_VERSION = "pymupdf-1.24+python-docx-1.1/v1"
PAGE_SEPARATOR = "\n\n"
MIN_CHARS_PER_PAGE_FOR_TEXT_LAYER = 20
# Resource ceilings. A file can pass the byte-size check and still be a
# decompression bomb: a few hundred kilobytes of PDF can declare tens of
# thousands of pages, or expand into gigabytes of text. Both are cheap to
# construct and expensive to parse, so the parser bounds its own work rather
# than trusting that upstream size validation was sufficient.
MAX_PAGES = 500
MAX_EXTRACTED_CHARS = 5_000_000 # ~5 MB of text
ParseStatus = Literal["ok", "low_yield", "failed"]
@dataclass(frozen=True, slots=True)
class TextSpan:
"""One styled run of text with the geometry needed to judge visibility.
The sanitizer cannot detect white-on-white or off-canvas text from a plain
string, so these attributes are extracted in the same pass as the text.
Attributes:
text: The span's characters.
page: One-based page number.
size: Font size in points.
color: Packed sRGB integer as reported by the PDF, or None if unknown.
bbox: (x0, y0, x1, y1) in page coordinates, or None if unknown.
page_rect: The page's own (x0, y0, x1, y1), for off-canvas comparison.
"""
text: str
page: int
size: float
color: int | None
bbox: tuple[float, float, float, float] | None
page_rect: tuple[float, float, float, float] | None
@dataclass(frozen=True, slots=True)
class ParsedPage:
"""One page of extracted text, anchored into the whole-document string.
Attributes:
page: One-based page number.
text: The page's extracted text.
start_char: Inclusive offset of `text` within `ParsedDocument.text`.
end_char: Exclusive offset of `text` within `ParsedDocument.text`.
"""
page: int
text: str
start_char: int
end_char: int
@dataclass(frozen=True, slots=True)
class ParsedDocument:
"""The result of parsing one resume or job description.
Attributes:
text: Full extracted text, the string all offsets index into.
pages: Per-page slices with exact offsets.
page_count: Number of pages found.
media_type: The media type actually parsed.
spans: Styled runs with geometry, consumed by the sanitizer.
metadata: Document metadata, an injection surface in its own right.
parser_version: Provenance for replay.
needs_ocr: True when the text layer is too thin to trust.
parse_status: "ok" when text was recovered, "low_yield" when OCR is needed.
warnings: Non-fatal notes raised during parsing.
"""
text: str
pages: tuple[ParsedPage, ...]
page_count: int
media_type: str
parser_version: str = PARSER_VERSION
needs_ocr: bool = False
parse_status: ParseStatus = "ok"
warnings: tuple[str, ...] = field(default=())
spans: tuple[TextSpan, ...] = field(default=())
metadata: dict[str, str] = field(default_factory=dict)
def _assemble(
pages: list[str],
media_type: str,
spans: tuple[TextSpan, ...] = (),
metadata: dict[str, str] | None = None,
) -> ParsedDocument:
"""Join page texts and compute exact offsets for each page.
Offsets are derived from the same concatenation that produces `text`, so
`text[start_char:end_char] == page.text` holds by construction rather than
by arithmetic that could drift.
Args:
pages: Per-page extracted text, in order.
media_type: The media type that was parsed.
spans: Styled runs with geometry, when the format exposes them.
metadata: Document metadata, when the format exposes it.
Returns:
The assembled document.
"""
parts: list[ParsedPage] = []
cursor = 0
for index, page_text in enumerate(pages, start=1):
start = cursor
end = start + len(page_text)
parts.append(ParsedPage(page=index, text=page_text, start_char=start, end_char=end))
cursor = end + len(PAGE_SEPARATOR)
full_text = PAGE_SEPARATOR.join(pages)
total_chars = sum(len(p) for p in pages)
thin = total_chars < MIN_CHARS_PER_PAGE_FOR_TEXT_LAYER * max(len(pages), 1)
if thin:
logger.info(
"parse_low_yield",
page_count=len(pages),
total_chars=total_chars,
needs_ocr=True,
)
return ParsedDocument(
text=full_text,
pages=tuple(parts),
page_count=len(pages),
media_type=media_type,
needs_ocr=thin,
parse_status="low_yield" if thin else "ok",
spans=spans,
metadata=metadata or {},
)
def _extract_spans(page: fitz.Page, page_number: int) -> list[TextSpan]:
"""Collect styled runs and their geometry from one PDF page.
Uses the structured `dict` extraction rather than plain text so the
sanitizer can see font size, colour, and position — the attributes that
distinguish text a human can read from text hidden for a model to find.
Args:
page: The PyMuPDF page.
page_number: One-based page number.
Returns:
Every non-empty span on the page.
"""
rect = page.rect
page_rect = (rect.x0, rect.y0, rect.x1, rect.y1)
collected: list[TextSpan] = []
payload = page.get_text("dict")
for block in payload.get("blocks", ()):
for line in block.get("lines", ()):
for span in line.get("spans", ()):
text = str(span.get("text", ""))
if not text.strip():
continue
raw_bbox = span.get("bbox")
bbox: tuple[float, float, float, float] | None = None
if raw_bbox is not None and len(raw_bbox) == 4:
bbox = (
float(raw_bbox[0]),
float(raw_bbox[1]),
float(raw_bbox[2]),
float(raw_bbox[3]),
)
collected.append(
TextSpan(
text=text,
page=page_number,
size=float(span.get("size", 0.0)),
color=span.get("color"),
bbox=bbox,
page_rect=page_rect,
)
)
return collected
def _parse_pdf(content: bytes) -> ParsedDocument:
"""Extract text from a PDF using PyMuPDF.
Args:
content: Raw PDF bytes.
Returns:
The parsed document.
Raises:
DocumentParseError: If the PDF cannot be opened or read.
"""
try:
with fitz.open(stream=content, filetype="pdf") as document:
if document.page_count > MAX_PAGES:
logger.warning(
"parse_pdf_rejected_page_count",
page_count=document.page_count,
limit=MAX_PAGES,
)
raise DocumentParseError(
f"The PDF exceeds the {MAX_PAGES}-page limit."
)
pages: list[str] = []
spans: list[TextSpan] = []
total_chars = 0
for index, page in enumerate(document, start=1):
text = page.get_text("text")
total_chars += len(text)
if total_chars > MAX_EXTRACTED_CHARS:
logger.warning(
"parse_pdf_rejected_text_volume", limit=MAX_EXTRACTED_CHARS
)
raise DocumentParseError(
"The PDF expands to more text than this service will process."
)
pages.append(text)
spans.extend(_extract_spans(page, index))
metadata = {
str(k): str(v) for k, v in (document.metadata or {}).items() if v
}
except DocumentParseError:
raise
except Exception as err: # noqa: BLE001 - normalized to a domain error
logger.warning("parse_pdf_failed", error=str(err), error_type=type(err).__name__)
raise DocumentParseError("The PDF could not be opened.") from err
if not pages:
raise DocumentParseError("The PDF contains no pages.")
return _assemble(pages, "application/pdf", tuple(spans), metadata)
def _parse_docx(content: bytes) -> ParsedDocument:
"""Extract text from a DOCX using python-docx.
DOCX has no intrinsic pagination — pagination is a rendering property, not a
storage one — so the whole document is reported as a single logical page
rather than inventing page breaks that would make offsets meaningless.
Args:
content: Raw DOCX bytes.
Returns:
The parsed document.
Raises:
DocumentParseError: If the archive is not a readable Word document.
"""
try:
document = DocxDocument(io.BytesIO(content))
paragraphs = [p.text for p in document.paragraphs]
except (
PackageNotFoundError,
zipfile.BadZipFile,
ValueError,
KeyError,
OSError,
) as err:
logger.warning("parse_docx_failed", error=str(err), error_type=type(err).__name__)
raise DocumentParseError("The DOCX could not be opened.") from err
body = "\n".join(paragraphs)
if len(body) > MAX_EXTRACTED_CHARS:
logger.warning("parse_docx_rejected_text_volume", limit=MAX_EXTRACTED_CHARS)
raise DocumentParseError(
"The DOCX expands to more text than this service will process."
)
return _assemble([body], DOCX_MIME)
def parse_document(content: bytes, media_type: str) -> ParsedDocument:
"""Parse a resume or job description into text with exact offsets.
Args:
content: Raw file bytes.
media_type: The media type to parse as. Callers should pass the result
of `app.utils.parsing.detect_media_type`, not a client-supplied
header.
Returns:
The parsed document, with `needs_ocr` set when the text layer is thin.
Raises:
EmptyDocumentError: If `content` is empty.
UnsupportedMediaTypeError: If `media_type` is not PDF or DOCX.
DocumentParseError: If the document is structurally unreadable.
"""
if not content:
raise EmptyDocumentError()
if media_type == "application/pdf":
return _parse_pdf(content)
if media_type == DOCX_MIME:
return _parse_docx(content)
raise UnsupportedMediaTypeError()
|