Eng-Musa commited on
Commit
764f46a
·
1 Parent(s): bbb03cc

Update processor to use marker-pdf and deploy to HF

Browse files
.gitignore ADDED
Binary file (33 Bytes). View file
 
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 1. Use Hugging Face recommended Python image
2
+ FROM python:3.12.8-slim
3
+
4
+ # 2. Create non-root user (required by Hugging Face Spaces)
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ # 3. Set working directory
10
+ WORKDIR /app
11
+ USER root
12
+
13
+ # 4. Install system dependencies
14
+ RUN apt-get update && \
15
+ apt-get install -y --no-install-recommends \
16
+ curl \
17
+ pandoc && \
18
+ rm -rf /var/lib/apt/lists/*
19
+ USER user
20
+
21
+ # 5. Copy requirements first for caching
22
+ COPY --chown=user requirements.txt .
23
+
24
+ # 6. Upgrade pip & install Python dependencies
25
+ RUN python -m pip install --upgrade pip \
26
+ && pip install --no-cache-dir -r requirements.txt
27
+
28
+ # 7. Copy the rest of the app code
29
+ COPY --chown=user . .
30
+
31
+ # 8. Expose Hugging Face default port
32
+ EXPOSE 7860
33
+
34
+ # 9. Run Uvicorn server for FastAPI app (main.py is in the root of the processor directory)
35
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
__pycache__/main.cpython-312.pyc CHANGED
Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ
 
main.py CHANGED
@@ -1,58 +1,83 @@
 
 
1
  from typing import Any, Optional
2
 
3
  from datetime import datetime
4
  from fastapi import FastAPI, UploadFile, File
5
  from fastapi.responses import JSONResponse
6
  from pydantic import BaseModel
7
- from services.cv_pipeline import process
 
8
 
9
  app = FastAPI()
 
 
 
 
 
10
 
11
  class APIResponse(BaseModel):
12
  message: str
13
  statusCode: int
14
  payload: Optional[Any] = None
15
-
 
16
  # python -m uvicorn main:app --reload
17
  @app.get("/")
18
  def home():
19
- return APIResponse(
20
- message="Job Processor API is running",
21
- statusCode=200,
22
- payload=None
23
- )
24
 
25
  @app.post("/process-cv", response_model=APIResponse)
26
  async def process_cv(file: UploadFile = File(...)):
27
  if not file.filename:
28
- return JSONResponse(status_code=400, content={"message": "No file uploaded", "statusCode": 400, "payload": None})
29
-
30
- allowed_extensions = (".pdf", ".docx")
31
  if not file.filename.lower().endswith(allowed_extensions):
32
- return JSONResponse(
33
- status_code=400,
34
- content={
35
- "message": f"Invalid file format. Allowed formats are: {', '.join(allowed_extensions)}",
36
- "statusCode": 400,
37
- "payload": None
38
- }
39
  )
40
 
 
41
  try:
42
  start_time = datetime.now().isoformat()
43
  file_bytes = await file.read()
44
- result = process(file_bytes)
 
 
 
 
 
 
 
 
 
 
45
  end_time = datetime.now().isoformat()
46
-
47
- result["start_time"] = start_time
48
- result["end_time"] = end_time
49
 
50
  return APIResponse(
51
  message="CV processed successfully",
52
  statusCode=200,
53
- payload=result
 
 
 
 
 
 
 
 
 
54
  )
55
- except ValueError as e:
56
- return JSONResponse(status_code=400, content={"message": str(e), "statusCode": 400, "payload": None})
57
  except Exception as e:
58
- return JSONResponse(status_code=500, content={"message": f"An error occurred during processing: {str(e)}", "statusCode": 500, "payload": None})
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import tempfile
3
  from typing import Any, Optional
4
 
5
  from datetime import datetime
6
  from fastapi import FastAPI, UploadFile, File
7
  from fastapi.responses import JSONResponse
8
  from pydantic import BaseModel
9
+ from services.cv_converter import CVConverter
10
+ from fastapi.exceptions import RequestValidationError
11
 
12
  app = FastAPI()
13
+ @app.exception_handler(RequestValidationError)
14
+ async def validation_exception_handler(request, exc):
15
+ return APIResponse(message="File is required", statusCode=400)
16
+
17
+ converter = CVConverter()
18
 
19
  class APIResponse(BaseModel):
20
  message: str
21
  statusCode: int
22
  payload: Optional[Any] = None
23
+
24
+
25
  # python -m uvicorn main:app --reload
26
  @app.get("/")
27
  def home():
28
+ return APIResponse(message="Job Processor API is running", statusCode=200)
29
+
 
 
 
30
 
31
  @app.post("/process-cv", response_model=APIResponse)
32
  async def process_cv(file: UploadFile = File(...)):
33
  if not file.filename:
34
+ return APIResponse(message="No file uploaded", statusCode=400)
35
+
36
+ allowed_extensions = (".pdf", ".docx", ".doc")
37
  if not file.filename.lower().endswith(allowed_extensions):
38
+ return APIResponse(
39
+ message=f"Invalid file format. Allowed formats are: {', '.join(allowed_extensions)}",
40
+ statusCode=400
 
 
 
 
41
  )
42
 
43
+ tmp_path = None
44
  try:
45
  start_time = datetime.now().isoformat()
46
  file_bytes = await file.read()
47
+
48
+ suffix = Path(file.filename).suffix.lower()
49
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
50
+ tmp.write(file_bytes)
51
+ tmp_path = Path(tmp.name)
52
+
53
+ result = converter.convert(tmp_path)
54
+
55
+ if not result.success:
56
+ return APIResponse(message=result.error, statusCode=422)
57
+
58
  end_time = datetime.now().isoformat()
 
 
 
59
 
60
  return APIResponse(
61
  message="CV processed successfully",
62
  statusCode=200,
63
+ payload={
64
+ "markdown": result.markdown,
65
+ "file_type": result.file_type,
66
+ "method_used": result.method_used,
67
+ "is_scanned": result.is_scanned,
68
+ "page_count": result.page_count,
69
+ "warnings": result.warnings,
70
+ "start_time": start_time,
71
+ "end_time": end_time,
72
+ }
73
  )
74
+
 
75
  except Exception as e:
76
+ return APIResponse(
77
+ message=f"An error occurred during processing: {str(e)}",
78
+ statusCode=500
79
+ )
80
+
81
+ finally:
82
+ if tmp_path and tmp_path.exists():
83
+ tmp_path.unlink()
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+
3
+ # Web framework & server
4
+ fastapi
5
+ uvicorn[standard]
6
+ pydantic
7
+ python-multipart
8
+
9
+ # Document parsing
10
+ pdfplumber
11
+ python-docx
12
+ marker-pdf
13
+
14
+ # AI / ML Models
15
+ transformers
16
+ accelerate
17
+ torch
services/__pycache__/cv_converter.cpython-312.pyc ADDED
Binary file (24.7 kB). View file
 
services/cv_converter.py ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cv_converter.py
3
+ ===============
4
+ Converts CV / résumé files (PDF, DOCX, DOC) to clean Markdown.
5
+
6
+ Pipeline
7
+ --------
8
+ PDF → pdfplumber (scan detection) → Marker (with or without OCR)
9
+ DOCX → pypandoc → GitHub-Flavoured Markdown
10
+ DOC → LibreOffice (→ DOCX) → pypandoc → GFM
11
+
12
+ Why Markdown as the intermediate format?
13
+ • LLMs understand it natively → better job-match prompts
14
+ • Sentence-transformers get cleaner text → better embeddings
15
+ • Renders directly in the browser with zero extra work
16
+
17
+ Install
18
+ -------
19
+ pip install marker-pdf pdfplumber pypandoc
20
+ python -c "import pypandoc; pypandoc.download_pandoc()"
21
+
22
+ # For legacy .doc support:
23
+ # Ubuntu/Debian : sudo apt-get install libreoffice
24
+ # macOS : brew install --cask libreoffice
25
+
26
+ Usage
27
+ -----
28
+ from cv_converter import CVConverter
29
+
30
+ converter = CVConverter()
31
+ result = converter.convert("john_doe_cv.pdf")
32
+
33
+ if result: # bool(result) == result.success
34
+ print(result.markdown)
35
+ converter.save(result, "john_doe_cv.md")
36
+ else:
37
+ print("Failed:", result.error)
38
+ for w in result.warnings:
39
+ print("Warning:", w)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import re
46
+ import subprocess
47
+ import tempfile
48
+ from dataclasses import dataclass, field
49
+ from enum import Enum
50
+ from pathlib import Path
51
+ from typing import Optional
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+ # Value types
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ class ConversionMethod(str, Enum):
61
+ MARKER = "marker" # Marker – text-based PDF
62
+ MARKER_OCR = "marker_ocr" # Marker – forced OCR (scanned PDF)
63
+ PANDOC = "pandoc" # Pandoc – DOCX
64
+ PANDOC_VIA_LO = "pandoc_via_lo" # LibreOffice → Pandoc – legacy DOC
65
+
66
+
67
+ class FileType(str, Enum):
68
+ PDF = "pdf"
69
+ DOCX = "docx"
70
+ DOC = "doc"
71
+ UNKNOWN = "unknown"
72
+
73
+
74
+ @dataclass
75
+ class ConversionResult:
76
+ """
77
+ Returned by :py:meth:`CVConverter.convert`.
78
+
79
+ Always check ``.success`` (or ``bool(result)``) before reading
80
+ ``.markdown``.
81
+
82
+ Attributes
83
+ ----------
84
+ success : bool – True when conversion produced output.
85
+ markdown : str | None – The Markdown text (None on failure).
86
+ method_used : str | None – Which pipeline was used.
87
+ file_type : str | None – Detected file type ("pdf", "docx", …).
88
+ is_scanned : bool – True when OCR was required.
89
+ page_count : int – Page count (0 when unknown).
90
+ warnings : list[str] – Non-fatal notes (mixed PDF, OCR quality…).
91
+ error : str | None – Human-readable error message on failure.
92
+ """
93
+ success: bool
94
+ markdown: Optional[str] = None
95
+ method_used: Optional[str] = None
96
+ file_type: Optional[str] = None
97
+ is_scanned: bool = False
98
+ page_count: int = 0
99
+ warnings: list[str] = field(default_factory=list)
100
+ error: Optional[str] = None
101
+
102
+ def __bool__(self) -> bool:
103
+ return self.success
104
+
105
+
106
+ # ─────────────────────────────────────────────────────────────────────────────
107
+ # Main class
108
+ # ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ class CVConverter:
111
+ """
112
+ Converts CV files (PDF / DOCX / DOC) to clean Markdown.
113
+
114
+ Marker ML models are loaded lazily on the first PDF call and then
115
+ cached, so all subsequent conversions in the same process reuse them.
116
+
117
+ Parameters
118
+ ----------
119
+ temp_dir : str | None
120
+ Where to place intermediate files (e.g. the .docx produced when
121
+ converting a legacy .doc). Defaults to the OS temp directory.
122
+ marker_device : str
123
+ ``"cpu"`` or ``"cuda"``. Passed to Marker when loading models.
124
+
125
+ Examples
126
+ --------
127
+ >>> converter = CVConverter()
128
+ >>> result = converter.convert("resume.pdf")
129
+ >>> if result:
130
+ ... converter.save(result, "resume.md")
131
+ """
132
+
133
+ # ── tuneable thresholds ───────────────────────────────────────────────
134
+
135
+ # Characters needed on a page before we call it "text bearing"
136
+ MIN_TEXT_CHARS_PER_PAGE: int = 50
137
+ # Fraction of pages that must carry text to skip OCR
138
+ SCANNED_PAGE_RATIO: float = 0.30
139
+ # Hard timeout for the LibreOffice subprocess (seconds)
140
+ LIBREOFFICE_TIMEOUT: int = 60
141
+
142
+ # ─────────────────────────────────────────────────────────────────────
143
+ def __init__(
144
+ self,
145
+ temp_dir: Optional[str] = None,
146
+ marker_device: str = "cpu",
147
+ ) -> None:
148
+ self.temp_dir = Path(temp_dir or tempfile.gettempdir())
149
+ self.marker_device = marker_device
150
+ self._marker_models = None # populated on first use
151
+
152
+ # ─────────────────────────────────────────────────────────────────────
153
+ # Public API
154
+ # ─────────────────────────────────────────────────────────────────────
155
+
156
+ def convert(self, file_path: str | Path) -> ConversionResult:
157
+ """
158
+ Convert a CV file to Markdown.
159
+
160
+ Automatically detects the file type and delegates to the correct
161
+ sub-pipeline. Never raises; all errors are captured in the
162
+ returned :class:`ConversionResult`.
163
+
164
+ Parameters
165
+ ----------
166
+ file_path : str | Path
167
+ Path to the CV file (.pdf, .docx, or .doc).
168
+
169
+ Returns
170
+ -------
171
+ ConversionResult
172
+ """
173
+ path = Path(file_path)
174
+
175
+ # ── pre-flight checks ─────────────────────────────────────────────
176
+ if not path.exists():
177
+ return ConversionResult(
178
+ success=False, error=f"File not found: {path}"
179
+ )
180
+ if not path.is_file():
181
+ return ConversionResult(
182
+ success=False, error=f"Path is not a regular file: {path}"
183
+ )
184
+ if path.stat().st_size == 0:
185
+ return ConversionResult(
186
+ success=False, error=f"File is empty: {path.name}"
187
+ )
188
+
189
+ file_type = self._detect_file_type(path)
190
+
191
+ if file_type == FileType.PDF:
192
+ return self._convert_pdf(path)
193
+
194
+ if file_type in (FileType.DOCX, FileType.DOC):
195
+ return self._convert_word(path, file_type)
196
+
197
+ return ConversionResult(
198
+ success=False,
199
+ error=(
200
+ f"Unsupported file type '{path.suffix}'. "
201
+ "Accepted: .pdf, .docx, .doc"
202
+ ),
203
+ )
204
+
205
+ def save(
206
+ self,
207
+ result: ConversionResult,
208
+ output_path: str | Path,
209
+ ) -> None:
210
+ """
211
+ Write ``result.markdown`` to *output_path* (UTF-8).
212
+
213
+ Raises
214
+ ------
215
+ ValueError
216
+ When ``result.success`` is False.
217
+ """
218
+ if not result.success:
219
+ raise ValueError("Cannot save a failed ConversionResult.")
220
+ out = Path(output_path)
221
+ out.parent.mkdir(parents=True, exist_ok=True)
222
+ out.write_text(result.markdown, encoding="utf-8")
223
+ logger.info("Markdown written → %s", out)
224
+
225
+ def convert_and_save(
226
+ self,
227
+ file_path: str | Path,
228
+ output_path: str | Path,
229
+ ) -> ConversionResult:
230
+ """Convenience wrapper: convert then save if successful."""
231
+ result = self.convert(file_path)
232
+ if result.success:
233
+ self.save(result, output_path)
234
+ return result
235
+
236
+ # ─────────────────────────────────────────────────────────────────────
237
+ # File-type detection
238
+ # ─────────────────────────────────────────────────────────────────────
239
+
240
+ @staticmethod
241
+ def _detect_file_type(path: Path) -> FileType:
242
+ return {
243
+ ".pdf": FileType.PDF,
244
+ ".docx": FileType.DOCX,
245
+ ".doc": FileType.DOC,
246
+ }.get(path.suffix.lower(), FileType.UNKNOWN)
247
+
248
+ # ─────────────────────────────────────────────────────────────────────
249
+ # PDF pipeline
250
+ # ─────────────────────────────────────────────────────────────────────
251
+
252
+ def _check_pdf_text_layer(
253
+ self, path: Path
254
+ ) -> tuple[bool, int, list[bool]]:
255
+ """
256
+ Use pdfplumber to probe each page for an embedded text layer.
257
+
258
+ Returns
259
+ -------
260
+ (has_text_layer, page_count, per_page_flags)
261
+ * has_text_layer – True when enough pages carry real text.
262
+ * page_count – Total pages.
263
+ * per_page_flags – Per-page bool list (True = text found).
264
+
265
+ Raises
266
+ ------
267
+ ValueError
268
+ For password-protected or zero-page PDFs.
269
+ """
270
+ try:
271
+ import pdfplumber
272
+ except ImportError:
273
+ logger.warning(
274
+ "pdfplumber not installed — OCR detection skipped. "
275
+ "Run: pip install pdfplumber"
276
+ )
277
+ return True, 0, []
278
+
279
+ try:
280
+ with pdfplumber.open(str(path)) as pdf:
281
+ page_count = len(pdf.pages)
282
+
283
+ if page_count == 0:
284
+ raise ValueError(
285
+ f"'{path.name}' has zero pages and cannot be converted."
286
+ )
287
+
288
+ per_page: list[bool] = []
289
+ for page in pdf.pages:
290
+ raw_text = page.extract_text() or ""
291
+ per_page.append(
292
+ len(raw_text.strip()) >= self.MIN_TEXT_CHARS_PER_PAGE
293
+ )
294
+
295
+ pages_with_text = sum(per_page)
296
+ ratio = pages_with_text / page_count
297
+ has_text_layer = ratio >= self.SCANNED_PAGE_RATIO
298
+
299
+ logger.debug(
300
+ "Text-layer check: %d/%d pages have text (%.0f%%)",
301
+ pages_with_text, page_count, ratio * 100,
302
+ )
303
+ return has_text_layer, page_count, per_page
304
+
305
+ except Exception as exc:
306
+ msg = str(exc).lower()
307
+ if any(k in msg for k in ("password", "encrypted", "decrypt")):
308
+ raise ValueError(
309
+ f"'{path.name}' is password-protected. "
310
+ "Please provide an unlocked copy."
311
+ ) from exc
312
+ # Unknown pdfplumber error → assume text-based; Marker will cope.
313
+ logger.warning(
314
+ "pdfplumber probe failed (%s) — assuming text-based PDF.", exc
315
+ )
316
+ return True, 0, []
317
+
318
+ def _load_marker_models(self):
319
+ """Lazy-load and cache Marker model dict (once per process)."""
320
+ if self._marker_models is None:
321
+ logger.info(
322
+ "Loading Marker models for the first time "
323
+ "(this may take ~10–30 s)…"
324
+ )
325
+ try:
326
+ from marker.models import create_model_dict # v1.x API
327
+ self._marker_models = create_model_dict()
328
+ logger.info("Marker models loaded and cached.")
329
+ except ImportError as exc:
330
+ raise ImportError(
331
+ "Marker is not installed. Fix: pip install marker-pdf"
332
+ ) from exc
333
+ return self._marker_models
334
+
335
+ def _run_marker(self, path: Path, force_ocr: bool = False) -> str:
336
+ """
337
+ Execute Marker and return the raw Markdown string.
338
+
339
+ Parameters
340
+ ----------
341
+ force_ocr : bool
342
+ When True, Marker is told to OCR every page regardless of
343
+ whether it detects an embedded text layer.
344
+
345
+ Raises
346
+ ------
347
+ RuntimeError
348
+ When Marker raises or returns empty output.
349
+ """
350
+ model_dict = self._load_marker_models()
351
+ try:
352
+ from marker.converters.pdf import PdfConverter # v1.x API
353
+ from marker.output import text_from_rendered # v1.x API
354
+
355
+ config = {"force_ocr": force_ocr} if force_ocr else {}
356
+
357
+ converter = PdfConverter(model_dict, config=config)
358
+ rendered = converter(str(path))
359
+ full_text, _images, _meta = text_from_rendered(rendered)
360
+
361
+ except Exception as exc:
362
+ raise RuntimeError(
363
+ f"Marker raised an exception on '{path.name}': {exc}"
364
+ ) from exc
365
+
366
+ if not full_text or not full_text.strip():
367
+ raise RuntimeError(
368
+ f"Marker produced empty output for '{path.name}'. "
369
+ "The file may be image-only or severely corrupted."
370
+ )
371
+ return full_text
372
+
373
+ def _convert_pdf(self, path: Path) -> ConversionResult:
374
+ """
375
+ Full PDF → Markdown pipeline.
376
+
377
+ Decision tree
378
+ -------------
379
+ 1. pdfplumber inspects every page for a text layer.
380
+ 2a. Fully scanned (< SCANNED_PAGE_RATIO text pages)
381
+ → Marker + OCR.
382
+ 2b. Mixed pages (some pages lack text)
383
+ → Marker + OCR for consistency across all pages.
384
+ 2c. Fully digital → Marker without OCR.
385
+ 3. If step 2c fails, automatically retry with OCR as a fallback
386
+ (handles PDFs that report a text layer but are actually images).
387
+ """
388
+ warnings: list[str] = []
389
+
390
+ # ── 1. detect text layer ─────────────────────────────────────────
391
+ try:
392
+ has_text, page_count, per_page = self._check_pdf_text_layer(path)
393
+ except ValueError as exc:
394
+ return ConversionResult(success=False, error=str(exc))
395
+
396
+ # ── 2. decide OCR strategy ───────────────────────────────────────
397
+ scanned_page_nums: list[int] = [
398
+ i + 1 for i, flag in enumerate(per_page) if not flag
399
+ ]
400
+ is_mixed = bool(scanned_page_nums) and has_text
401
+ is_scanned = not has_text
402
+
403
+ if is_scanned:
404
+ force_ocr = True
405
+ method = ConversionMethod.MARKER_OCR
406
+ warnings.append(
407
+ "Document appears to be fully scanned. OCR was applied — "
408
+ "accuracy depends on scan quality."
409
+ )
410
+ elif is_mixed:
411
+ force_ocr = True
412
+ is_scanned = True
413
+ method = ConversionMethod.MARKER_OCR
414
+ warnings.append(
415
+ f"Mixed PDF: pages {scanned_page_nums} appear scanned. "
416
+ "OCR applied to the entire document for consistency."
417
+ )
418
+ else:
419
+ force_ocr = False
420
+ method = ConversionMethod.MARKER
421
+
422
+ # ── 3. convert ───────────────────────────────────────────────────
423
+ try:
424
+ markdown = self._run_marker(path, force_ocr=force_ocr)
425
+
426
+ except RuntimeError as exc:
427
+ if not force_ocr:
428
+ # Rare edge case: PDF claims a text layer but extraction
429
+ # fails (e.g. custom fonts, badly embedded text).
430
+ logger.warning(
431
+ "Marker (no OCR) failed on '%s': %s — retrying with OCR…",
432
+ path.name, exc,
433
+ )
434
+ try:
435
+ markdown = self._run_marker(path, force_ocr=True)
436
+ method = ConversionMethod.MARKER_OCR
437
+ is_scanned = True
438
+ warnings.append(
439
+ "Standard text extraction failed; OCR fallback was used."
440
+ )
441
+ except RuntimeError as fallback_exc:
442
+ return ConversionResult(
443
+ success=False,
444
+ error=(
445
+ f"All extraction methods failed for '{path.name}'. "
446
+ f"Last error: {fallback_exc}"
447
+ ),
448
+ )
449
+ else:
450
+ return ConversionResult(success=False, error=str(exc))
451
+
452
+ return ConversionResult(
453
+ success = True,
454
+ markdown = self._clean_markdown(markdown),
455
+ method_used = method.value,
456
+ file_type = FileType.PDF.value,
457
+ is_scanned = is_scanned,
458
+ page_count = page_count,
459
+ warnings = warnings,
460
+ )
461
+
462
+ # ─────────────────────────────────────────────────────────────────────
463
+ # Word pipeline
464
+ # ─────────────────────────────────────────────────────────────────────
465
+
466
+ def _convert_word(
467
+ self, path: Path, file_type: FileType
468
+ ) -> ConversionResult:
469
+ """
470
+ Convert DOCX (or legacy DOC) to GitHub-Flavoured Markdown via Pandoc.
471
+
472
+ For ``.doc`` files LibreOffice is used first to produce a ``.docx``
473
+ intermediate in ``self.temp_dir``, which Pandoc then converts.
474
+
475
+ Pandoc flags used
476
+ -----------------
477
+ ``--wrap=none`` No hard line-wrapping.
478
+ ``--strip-comments`` Drop Word tracked-change comments.
479
+ ``--markdown-headings=atx`` Use ``#`` style, not underline style.
480
+ """
481
+ try:
482
+ import pypandoc # noqa: F401 – just check it is installed
483
+ except ImportError:
484
+ return ConversionResult(
485
+ success=False,
486
+ error=(
487
+ "pypandoc is not installed.\n"
488
+ " pip install pypandoc\n"
489
+ " python -c \"import pypandoc; pypandoc.download_pandoc()\""
490
+ ),
491
+ )
492
+
493
+ warnings: list[str] = []
494
+ method = ConversionMethod.PANDOC
495
+
496
+ # ── .doc → .docx ─────────────────────────────────────────────────
497
+ if file_type == FileType.DOC:
498
+ try:
499
+ path, warnings = self._doc_to_docx(path, warnings)
500
+ method = ConversionMethod.PANDOC_VIA_LO
501
+ except RuntimeError as exc:
502
+ return ConversionResult(success=False, error=str(exc))
503
+
504
+ # ── DOCX → GFM Markdown ──────────────────────────────────────────
505
+ try:
506
+ import pypandoc
507
+
508
+ markdown = pypandoc.convert_file(
509
+ str(path),
510
+ to="gfm",
511
+ extra_args=[
512
+ "--wrap=none",
513
+ "--strip-comments",
514
+ "--markdown-headings=atx",
515
+ ],
516
+ )
517
+ except Exception as exc:
518
+ return ConversionResult(
519
+ success=False,
520
+ error=f"Pandoc conversion failed: {exc}",
521
+ )
522
+
523
+ if not markdown or not markdown.strip():
524
+ return ConversionResult(
525
+ success=False,
526
+ error=(
527
+ f"Pandoc returned empty output for '{path.name}'. "
528
+ "The document may be blank."
529
+ ),
530
+ )
531
+
532
+ return ConversionResult(
533
+ success = True,
534
+ markdown = self._clean_markdown(markdown),
535
+ method_used = method.value,
536
+ file_type = file_type.value,
537
+ is_scanned = False,
538
+ warnings = warnings,
539
+ )
540
+
541
+ def _doc_to_docx(
542
+ self, path: Path, warnings: list[str]
543
+ ) -> tuple[Path, list[str]]:
544
+ """
545
+ Convert a legacy ``.doc`` file to ``.docx`` via LibreOffice (headless).
546
+
547
+ Returns
548
+ -------
549
+ (docx_path, updated_warnings)
550
+
551
+ Raises
552
+ ------
553
+ RuntimeError
554
+ If LibreOffice is missing, exits non-zero, times out, or
555
+ produces no output file.
556
+ """
557
+ docx_path = self.temp_dir / (path.stem + ".docx")
558
+
559
+ try:
560
+ proc = subprocess.run(
561
+ [
562
+ "libreoffice",
563
+ "--headless",
564
+ "--convert-to", "docx",
565
+ "--outdir", str(self.temp_dir),
566
+ str(path),
567
+ ],
568
+ capture_output=True,
569
+ text=True,
570
+ timeout=self.LIBREOFFICE_TIMEOUT,
571
+ )
572
+ except FileNotFoundError:
573
+ raise RuntimeError(
574
+ "LibreOffice is required to convert legacy .doc files but "
575
+ "was not found on PATH.\n"
576
+ " Ubuntu/Debian : sudo apt-get install libreoffice\n"
577
+ " macOS : brew install --cask libreoffice"
578
+ )
579
+ except subprocess.TimeoutExpired:
580
+ raise RuntimeError(
581
+ f"LibreOffice timed out after {self.LIBREOFFICE_TIMEOUT} s "
582
+ f"converting '{path.name}'."
583
+ )
584
+
585
+ if proc.returncode != 0:
586
+ raise RuntimeError(
587
+ f"LibreOffice exited with code {proc.returncode}:\n"
588
+ f"{proc.stderr.strip()}"
589
+ )
590
+
591
+ if not docx_path.exists():
592
+ raise RuntimeError(
593
+ f"LibreOffice ran successfully but no .docx was produced. "
594
+ f"Expected: {docx_path}"
595
+ )
596
+
597
+ warnings.append(
598
+ "Legacy .doc file was automatically converted to .docx via "
599
+ "LibreOffice before Markdown extraction."
600
+ )
601
+ return docx_path, warnings
602
+
603
+ # ─────────────────────────────────────────────────────────────────────
604
+ # Post-processing helpers
605
+ # ─────────────────────────────────────────────────────────────────────
606
+
607
+ @staticmethod
608
+ def _clean_markdown(text: str) -> str:
609
+ """
610
+ Remove converter artefacts and normalise whitespace.
611
+
612
+ Handles
613
+ -------
614
+ * Windows / mixed line endings (CRLF → LF)
615
+ * Null bytes and Word private-use bullet characters
616
+ * Trailing whitespace per line
617
+ * Runs of 3+ blank lines → single blank line
618
+ * Marker page-break separators (standalone ``---`` / ``===`` lines)
619
+ * Pandoc footnote-like wrapping artefacts
620
+ """
621
+ # 1. Normalise line endings
622
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
623
+
624
+ # 2. Remove null bytes and common Word private-use characters
625
+ replacements = {
626
+ "\x00": "", # null byte
627
+ "\uf0b7": "-", # Word solid bullet •
628
+ "\uf0a7": "-", # Word hollow bullet ◦
629
+ "\uf020": " ", # Word private space
630
+ "\uf0fc": "-", # Word checkmark bullet
631
+ }
632
+ for bad, good in replacements.items():
633
+ text = text.replace(bad, good)
634
+
635
+ # 3. Strip trailing whitespace on every line
636
+ text = "\n".join(line.rstrip() for line in text.split("\n"))
637
+
638
+ # 4. Remove Marker's standalone page-break / rule lines
639
+ text = re.sub(r"(?m)^[-=]{3,}\s*$", "", text)
640
+
641
+ # 5. Collapse 3+ consecutive blank lines to one blank line
642
+ text = re.sub(r"\n{3,}", "\n\n", text)
643
+
644
+ return text.strip()
645
+
646
+
647
+ # ─────────────────────────────────────────────────────────────────────────────
648
+ # CLI convenience (python cv_converter.py <input> [output])
649
+ # ─────────────────────────────────────────────────────────────────────────────
650
+
651
+ def _cli() -> None:
652
+ import sys
653
+
654
+ logging.basicConfig(
655
+ level=logging.INFO,
656
+ format="%(levelname)s %(message)s",
657
+ )
658
+
659
+ args = sys.argv[1:]
660
+ if not args:
661
+ print(
662
+ "Usage: python cv_converter.py <input.(pdf|docx|doc)> [output.md]"
663
+ )
664
+ sys.exit(1)
665
+
666
+ input_path = Path(args[0])
667
+ output_path = Path(args[1]) if len(args) > 1 else input_path.with_suffix(".md")
668
+
669
+ converter = CVConverter()
670
+
671
+ print(f"Converting '{input_path}' …")
672
+ result = converter.convert(input_path)
673
+
674
+ if result.warnings:
675
+ for w in result.warnings:
676
+ print(f" ⚠ {w}")
677
+
678
+ if not result:
679
+ print(f"✗ Conversion failed: {result.error}")
680
+ sys.exit(1)
681
+
682
+ converter.save(result, output_path)
683
+ print(
684
+ f"✓ Done [{result.method_used}] "
685
+ f"{'(scanned) ' if result.is_scanned else ''}"
686
+ f"→ {output_path}"
687
+ )
688
+ if result.page_count:
689
+ print(f" Pages : {result.page_count}")
690
+ print(f" Size : {len(result.markdown):,} characters")
691
+
692
+
693
+ if __name__ == "__main__":
694
+ _cli()
services/cv_pipeline.py DELETED
@@ -1,335 +0,0 @@
1
- """
2
- CV Parser — CPU-optimised inference (no GPU required).
3
-
4
- Install:
5
- pip install pdfplumber python-docx transformers torch accelerate
6
- """
7
-
8
- import io
9
- import os
10
- import re
11
- import json
12
- import logging
13
- import time
14
- import pdfplumber
15
- from docx import Document
16
- from docx.oxml.ns import qn
17
- from transformers import AutoModelForCausalLM, AutoTokenizer
18
- import torch
19
-
20
-
21
- # ── Logging ───────────────────────────────────────────────────────────────────
22
-
23
- logging.basicConfig(
24
- level=logging.INFO,
25
- format="%(asctime)s [%(levelname)-8s] %(message)s",
26
- datefmt="%H:%M:%S",
27
- )
28
- log = logging.getLogger("cv_parser")
29
-
30
- DIVIDER = "─" * 60
31
-
32
-
33
- # ── CPU optimisation flags ────────────────────────────────────────────────────
34
-
35
- torch.set_num_threads(os.cpu_count() or 4)
36
-
37
-
38
- # ── Constants ─────────────────────────────────────────────────────────────────
39
-
40
- MAX_CHARS = 15_000
41
- MAX_NEW_TOKENS = 4000
42
- HF_TOKEN = os.getenv("HF_TOKEN", "")
43
- MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
44
-
45
- SYSTEM_PROMPT = """\
46
- You are a CV parser. Output ONLY a raw JSON object with this exact schema:
47
- {"contact":{"full_name":null,"email":null,"phone":null,"location":null},
48
- "links":{"linkedin":null,"github":null,"portfolio":null,"other":[]},
49
- "summary":null,
50
- "experience":[{"company":null,"title":null,"start_date":null,"end_date":null,"description":[]}],
51
- "education":[{"institution":null,"degree":null,"field_of_study":null,"start_date":null,"end_date":null}],
52
- "skills":[],"certifications":[{"name":null,"issuer":null,"date":null}],"languages":[]}
53
-
54
- Rules:
55
- - CRITICAL: DO NOT SUMMARIZE or omit any information. Extract all bullet points, skills, and descriptions verbatim to ensure zero information loss.
56
- - links: classify linkedin.com→linkedin, github.com→github, personal site→portfolio, else→other[]
57
- - Do NOT invent URLs absent from the text. Use null/[] when data is missing.
58
- - Preserve original date formats. No markdown fences, no commentary."""
59
-
60
-
61
- # ── Model backend (loaded once at startup) ────────────────────────────────────
62
-
63
- class _ModelBackend:
64
- """
65
- Wraps tokenizer + model in one object so there is no risk of module-level
66
- name collisions between the old `llm` pipeline variable and the new
67
- `tokenizer`/`model` pair when the server hot-reloads.
68
- """
69
-
70
- def __init__(self):
71
- log.info("Loading model %s …", MODEL_ID)
72
- t0 = time.perf_counter()
73
-
74
- self.tokenizer = AutoTokenizer.from_pretrained(
75
- MODEL_ID, token=HF_TOKEN or None
76
- )
77
-
78
- self.model = AutoModelForCausalLM.from_pretrained(
79
- MODEL_ID,
80
- token=HF_TOKEN or None,
81
- # bfloat16: Qwen2.5 was trained in bf16 — zero quality loss,
82
- # halves memory bandwidth vs float32 (~1.8× faster on AVX512 CPUs)
83
- torch_dtype=torch.bfloat16, # keeping torch_dtype as that is standard for transformers despite the warning
84
- device_map="cpu",
85
- attn_implementation="eager",
86
- )
87
- self.model.eval()
88
-
89
- # torch.compile is disabled because it compiles lazily on the first
90
- # generate() call, which currently throws RuntimeErrors on Windows CPU.
91
- # This prevents the 500 Internal Server Error during STEP 2.
92
-
93
- log.info("Model ready in %.1fs", time.perf_counter() - t0)
94
-
95
- def generate(self, text: str) -> str:
96
- """Run inference and return the raw decoded output string."""
97
- messages = [
98
- {"role": "system", "content": SYSTEM_PROMPT},
99
- {"role": "user", "content": text},
100
- ]
101
- inputs = self.tokenizer.apply_chat_template(
102
- messages,
103
- add_generation_prompt=True,
104
- return_tensors="pt",
105
- return_dict=True,
106
- )
107
-
108
- with torch.no_grad(), torch.inference_mode():
109
- output_ids = self.model.generate(
110
- **inputs,
111
- max_new_tokens=MAX_NEW_TOKENS,
112
- do_sample=False,
113
- repetition_penalty=1.1,
114
- pad_token_id=self.tokenizer.eos_token_id,
115
- use_cache=True,
116
- )
117
-
118
- # Slice off the prompt tokens — decode only what the model generated
119
- new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
120
- return self.tokenizer.decode(new_tokens, skip_special_tokens=True)
121
-
122
-
123
- # Singleton — initialised once when the module is first imported
124
- _backend = _ModelBackend()
125
-
126
-
127
- # ── URL helpers ───────────────────────────────────────────────────────────────
128
-
129
- _URL_RE = re.compile(
130
- r"https?://[^\s,;\"'<>()\[\]]+|(?:www\.)[a-zA-Z0-9\-]+\.[a-zA-Z]{2,}[^\s,;\"'<>()\[\]]*",
131
- re.I,
132
- )
133
- _LINKEDIN_RE = re.compile(r"linkedin\.com", re.I)
134
- _GITHUB_RE = re.compile(r"github\.com", re.I)
135
- # Schemes that are not navigable URLs and must never be classified as links
136
- _SKIP_SCHEMES = re.compile(r"^(mailto|tel|sms|fax|callto):", re.I)
137
-
138
-
139
- def _empty_links() -> dict:
140
- return {"linkedin": None, "github": None, "portfolio": None, "other": []}
141
-
142
-
143
- def _classify_url(url: str, bucket: dict) -> None:
144
- url = url.rstrip(".,;)")
145
- if not url or _SKIP_SCHEMES.match(url): # drop mailto:, tel:, etc.
146
- return
147
- if _LINKEDIN_RE.search(url):
148
- bucket["linkedin"] = bucket["linkedin"] or url
149
- elif _GITHUB_RE.search(url):
150
- bucket["github"] = bucket["github"] or url
151
- else:
152
- if bucket["portfolio"] is None:
153
- bucket["portfolio"] = url
154
- elif url not in bucket["other"]:
155
- bucket["other"].append(url)
156
-
157
-
158
- # ── File extraction ───────────────────────────────────────────────────────────
159
-
160
- def extract(file_bytes: bytes) -> tuple[str, dict]:
161
- if file_bytes[:4] == b"%PDF":
162
- return _from_pdf(file_bytes)
163
- if file_bytes[:2] == b"PK":
164
- return _from_docx(file_bytes)
165
- raise ValueError("Unsupported file type. Upload a PDF or DOCX.")
166
-
167
-
168
- def _from_pdf(data: bytes) -> tuple[str, dict]:
169
- parts: list[str] = []
170
- pre_links = _empty_links()
171
-
172
- with pdfplumber.open(io.BytesIO(data)) as pdf:
173
- for page in pdf.pages:
174
- for h in (page.hyperlinks or []):
175
- uri = h.get("uri", "")
176
- if uri:
177
- _classify_url(uri, pre_links)
178
- t = page.extract_text(x_tolerance=2, y_tolerance=2)
179
- if t:
180
- parts.append(t)
181
-
182
- text = "\n".join(parts)
183
- for url in _URL_RE.findall(text):
184
- _classify_url(url, pre_links)
185
-
186
- log.info("STEP 1 — PDF: %d chars | links: %s", len(text), pre_links)
187
- return text, pre_links
188
-
189
-
190
- def _from_docx(data: bytes) -> tuple[str, dict]:
191
- doc = Document(io.BytesIO(data))
192
- parts: list[str] = []
193
- pre_links = _empty_links()
194
-
195
- for hl in doc.element.body.iter(qn("w:hyperlink")):
196
- r_id = hl.get(qn("r:id"))
197
- if r_id and r_id in doc.part.rels:
198
- rel = doc.part.rels[r_id]
199
- if "hyperlink" in rel.reltype:
200
- _classify_url(rel.target_ref, pre_links)
201
-
202
- for child in doc.element.body:
203
- tag = child.tag.split("}")[-1]
204
- if tag == "p":
205
- parts.append("".join(n.text or "" for n in child.iter(qn("w:t"))))
206
- elif tag == "tbl":
207
- for row in child.iter(qn("w:tr")):
208
- cells = [
209
- "".join(n.text or "" for n in cell.iter(qn("w:tc")))
210
- for cell in row.iter(qn("w:tc"))
211
- ]
212
- parts.append("\t".join(cells))
213
-
214
- text = "\n".join(p for p in parts if p.strip())
215
- for url in _URL_RE.findall(text):
216
- _classify_url(url, pre_links)
217
-
218
- log.info("STEP 1 — DOCX: %d chars | links: %s", len(text), pre_links)
219
- return text, pre_links
220
-
221
-
222
- # ── Clean ─────────────────────────────────────────────────────────────────────
223
-
224
- _BULLET_RE = re.compile(r"^[\s•·▪▸►▶‣◦\-–—]+", re.MULTILINE)
225
- _BLANK_RE = re.compile(r"\n{3,}")
226
- _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
227
- _MULTI_SP_RE = re.compile(r" +")
228
-
229
-
230
- def clean(text: str) -> str:
231
- text = re.sub(r"-\s*\n\s*", "", text)
232
- text = _CONTROL_RE.sub("", text)
233
- text = (
234
- text
235
- .replace("\u00a0", " ")
236
- .replace("\u2013", "-")
237
- .replace("\u2014", "-")
238
- .replace("\u2022", "")
239
- .replace("\u25cf", "")
240
- )
241
- text = _BULLET_RE.sub("", text)
242
- text = _MULTI_SP_RE.sub(" ", text)
243
- text = _BLANK_RE.sub("\n\n", text)
244
- return text.strip()
245
-
246
-
247
- # ── LLM call ──────────────────────────────────────────────────────────────────
248
-
249
- def call_llm(text: str) -> dict:
250
- if len(text) > MAX_CHARS:
251
- text = text[:MAX_CHARS]
252
-
253
- log.info("STEP 2 — LLM input (%d chars)\n%s\n%s\n%s", len(text), DIVIDER, text, DIVIDER)
254
-
255
- t0 = time.perf_counter()
256
- raw = _backend.generate(text)
257
- log.info("STEP 3 — LLM finished in %.1fs", time.perf_counter() - t0)
258
- log.info("LLM output:\n%s\n%s\n%s", DIVIDER, raw, DIVIDER)
259
-
260
- # Strip accidental markdown fences
261
- raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip())
262
-
263
- try:
264
- return json.loads(raw)
265
- except json.JSONDecodeError as exc:
266
- log.error("JSON parse failed: %s\nOffending:\n%s", exc, raw[:500])
267
- raise ValueError(f"LLM returned invalid JSON: {exc}") from exc
268
-
269
-
270
- # ── Normalize & merge links ───────────────────────────────────────────────────
271
-
272
- def _normalize_llm_links(raw_links) -> dict:
273
- out = _empty_links()
274
- if isinstance(raw_links, dict):
275
- out["linkedin"] = raw_links.get("linkedin") or None
276
- out["github"] = raw_links.get("github") or None
277
- out["portfolio"] = raw_links.get("portfolio") or None
278
- for url in (raw_links.get("other") or []):
279
- if isinstance(url, str) and url not in out["other"]:
280
- out["other"].append(url)
281
- elif isinstance(raw_links, list):
282
- for url in raw_links:
283
- if isinstance(url, str):
284
- _classify_url(url, out)
285
- return out
286
-
287
-
288
- def _merge_links(llm_result: dict, pre_links: dict) -> dict:
289
- llm_links = _normalize_llm_links(llm_result.get("links"))
290
- merged = {
291
- "linkedin": pre_links["linkedin"] or llm_links["linkedin"],
292
- "github": pre_links["github"] or llm_links["github"],
293
- "portfolio": pre_links["portfolio"] or llm_links["portfolio"],
294
- "other": sorted({*llm_links["other"], *pre_links["other"]}),
295
- }
296
- llm_result["links"] = merged
297
- log.info("STEP 4 — Merged links: %s", merged)
298
- return llm_result
299
-
300
-
301
- # ── Main pipeline ─────────────────────────────────────────────────────────────
302
-
303
- def process(file_bytes: bytes) -> dict:
304
- t_start = time.perf_counter()
305
-
306
- raw_text, pre_links = extract(file_bytes)
307
- if not raw_text.strip():
308
- raise ValueError("No text could be extracted from the document.")
309
-
310
- cleaned = clean(raw_text)
311
- result = call_llm(cleaned)
312
- result = _merge_links(result, pre_links)
313
- result["raw_text"] = cleaned
314
-
315
- log.info("Pipeline complete in %.1fs", time.perf_counter() - t_start)
316
- return result
317
-
318
-
319
- # ── CLI ───────────────────────────────────────────────────────────────────────
320
-
321
- if __name__ == "__main__":
322
- import sys
323
- from pathlib import Path
324
-
325
- if len(sys.argv) < 2:
326
- print("Usage: python cv_parser.py <path/to/cv.pdf|.docx>")
327
- sys.exit(1)
328
-
329
- path = Path(sys.argv[1])
330
- if not path.exists():
331
- print(f"File not found: {path}")
332
- sys.exit(1)
333
-
334
- parsed = process(path.read_bytes())
335
- print(json.dumps(parsed, indent=2, ensure_ascii=False))