Eng-Musa commited on
Commit
9d16a7f
·
1 Parent(s): 70da0b3

add process cv async

Browse files
__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,17 +1,22 @@
1
  from __future__ import annotations
2
 
3
  import tempfile
 
4
  from datetime import datetime, timezone
5
  from pathlib import Path
6
  from typing import Any, Optional
7
 
8
- from fastapi import FastAPI, File, UploadFile
9
  from fastapi.exceptions import RequestValidationError
 
10
  from pydantic import BaseModel, Field
 
11
 
 
12
  from services.cv_chunker import chunk_cv
13
  from services.cv_converter import CVConverter
14
  from services.job_matcher import JobInput, JobMatcher
 
15
 
16
  # ---------------------------------------------------------------------------
17
  # App setup
@@ -25,6 +30,7 @@ app = FastAPI(
25
 
26
  converter = CVConverter()
27
  matcher = JobMatcher() # SentenceTransformer loaded once at startup
 
28
 
29
 
30
  # ---------------------------------------------------------------------------
@@ -36,20 +42,13 @@ class APIResponse(BaseModel):
36
  statusCode: int
37
  payload: Optional[Any] = None
38
 
39
-
40
- # ---------------------------------------------------------------------------
41
- # Validation error handler
42
- # ---------------------------------------------------------------------------
43
-
44
- from fastapi.responses import JSONResponse
45
- from starlette.exceptions import HTTPException as StarletteHTTPException
46
-
47
  @app.exception_handler(RequestValidationError)
48
  async def validation_exception_handler(request, exc):
49
  return JSONResponse(
50
  status_code=400,
51
  content=APIResponse(
52
- message="Invalid request: " + str(exc.errors()),
53
  statusCode=400
54
  ).model_dump()
55
  )
@@ -77,22 +76,14 @@ async def global_exception_handler(request, exc):
77
 
78
  # ---------------------------------------------------------------------------
79
  # Health check
80
- # ---------------------------------------------------------------------------
81
-
82
  @app.get("/", response_model=APIResponse)
83
  def home():
84
  return APIResponse(message="Job Processor API is running", statusCode=200)
85
 
86
 
87
- # ---------------------------------------------------------------------------
88
- # POST /process-cv
89
- # Accepts a PDF / DOCX / DOC file, converts it to Markdown,
90
- # then runs the CV chunker to return structured sections.
91
- # ---------------------------------------------------------------------------
92
 
 
93
  _ALLOWED_EXT = (".pdf", ".docx", ".doc")
94
-
95
-
96
  @app.post("/process-cv", response_model=APIResponse)
97
  async def process_cv(file: UploadFile = File(...)):
98
  """
@@ -121,12 +112,10 @@ async def process_cv(file: UploadFile = File(...)):
121
  tmp.write(file_bytes)
122
  tmp_path = Path(tmp.name)
123
 
124
- # Convert file → Markdown
125
  conversion = converter.convert(tmp_path)
126
  if not conversion.success:
127
  return APIResponse(message=conversion.error or "Conversion failed", statusCode=422)
128
 
129
- # Chunk the Markdown into structured sections
130
  chunks = chunk_cv(conversion.markdown)
131
  end_time = datetime.now(timezone.utc).isoformat()
132
 
@@ -149,13 +138,13 @@ async def process_cv(file: UploadFile = File(...)):
149
  "projects": chunks["chunks"]["projects"],
150
  "awards": chunks["chunks"]["awards"],
151
  },
152
- "file_type": conversion.file_type,
153
- "method_used": conversion.method_used,
154
- "is_scanned": conversion.is_scanned,
155
- "page_count": conversion.page_count,
156
- "warnings": conversion.warnings,
157
- "start_time": start_time,
158
- "end_time": end_time,
159
  },
160
  )
161
 
@@ -171,11 +160,78 @@ async def process_cv(file: UploadFile = File(...)):
171
 
172
 
173
  # ---------------------------------------------------------------------------
174
- # POST /match-cv
175
- # Accepts a structured job JSON + a CV Markdown string,
176
- # returns a full qualification prediction.
 
 
177
  # ---------------------------------------------------------------------------
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  class MatchCVRequest(BaseModel):
180
  """Request body for POST /match-cv."""
181
  job: JobInput = Field(..., description="Structured job posting to match against.")
 
1
  from __future__ import annotations
2
 
3
  import tempfile
4
+ import httpx
5
  from datetime import datetime, timezone
6
  from pathlib import Path
7
  from typing import Any, Optional
8
 
9
+ from fastapi import BackgroundTasks, FastAPI, File, Form, UploadFile
10
  from fastapi.exceptions import RequestValidationError
11
+ from fastapi.responses import JSONResponse
12
  from pydantic import BaseModel, Field
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
14
 
15
+ from services import job_store
16
  from services.cv_chunker import chunk_cv
17
  from services.cv_converter import CVConverter
18
  from services.job_matcher import JobInput, JobMatcher
19
+ from services.workers import CvWorker
20
 
21
  # ---------------------------------------------------------------------------
22
  # App setup
 
30
 
31
  converter = CVConverter()
32
  matcher = JobMatcher() # SentenceTransformer loaded once at startup
33
+ cv_worker = CvWorker(converter)
34
 
35
 
36
  # ---------------------------------------------------------------------------
 
42
  statusCode: int
43
  payload: Optional[Any] = None
44
 
45
+ # Exception handlers
 
 
 
 
 
 
 
46
  @app.exception_handler(RequestValidationError)
47
  async def validation_exception_handler(request, exc):
48
  return JSONResponse(
49
  status_code=400,
50
  content=APIResponse(
51
+ message="Invalid request: " + str(exc.errors()),
52
  statusCode=400
53
  ).model_dump()
54
  )
 
76
 
77
  # ---------------------------------------------------------------------------
78
  # Health check
 
 
79
  @app.get("/", response_model=APIResponse)
80
  def home():
81
  return APIResponse(message="Job Processor API is running", statusCode=200)
82
 
83
 
 
 
 
 
 
84
 
85
+ # POST /process-cv (synchronous)
86
  _ALLOWED_EXT = (".pdf", ".docx", ".doc")
 
 
87
  @app.post("/process-cv", response_model=APIResponse)
88
  async def process_cv(file: UploadFile = File(...)):
89
  """
 
112
  tmp.write(file_bytes)
113
  tmp_path = Path(tmp.name)
114
 
 
115
  conversion = converter.convert(tmp_path)
116
  if not conversion.success:
117
  return APIResponse(message=conversion.error or "Conversion failed", statusCode=422)
118
 
 
119
  chunks = chunk_cv(conversion.markdown)
120
  end_time = datetime.now(timezone.utc).isoformat()
121
 
 
138
  "projects": chunks["chunks"]["projects"],
139
  "awards": chunks["chunks"]["awards"],
140
  },
141
+ "file_type": conversion.file_type,
142
+ "method_used": conversion.method_used,
143
+ "is_scanned": conversion.is_scanned,
144
+ "page_count": conversion.page_count,
145
+ "warnings": conversion.warnings,
146
+ "start_time": start_time,
147
+ "end_time": end_time,
148
  },
149
  )
150
 
 
160
 
161
 
162
  # ---------------------------------------------------------------------------
163
+ # POST /process-cv-async (NEW — used by Java async worker)
164
+ #
165
+ # Accepts the CV file plus job_id and callback_url as form fields.
166
+ # Returns 202 immediately and processes the CV in a BackgroundTask.
167
+ # On completion (success or failure) POSTs the result to callback_url.
168
  # ---------------------------------------------------------------------------
169
 
170
+
171
+ @app.post("/process-cv-async", response_model=APIResponse)
172
+ async def process_cv_async(
173
+ background_tasks: BackgroundTasks,
174
+ file: UploadFile = File(...),
175
+ job_id: str = Form(...),
176
+ callback_url: str = Form(...),
177
+ callback_secret: str = Form(None),
178
+ ):
179
+ """
180
+ Async entry point called by the Java async worker.
181
+ Accepts the CV file + job_id + callback_url as multipart form fields.
182
+ Returns 202 immediately; processes the CV in a background task.
183
+ """
184
+ if not file.filename:
185
+ return APIResponse(message="No file uploaded", statusCode=400)
186
+
187
+ if not file.filename.lower().endswith(_ALLOWED_EXT):
188
+ return APIResponse(
189
+ message=f"Invalid file format. Allowed formats: {', '.join(_ALLOWED_EXT)}",
190
+ statusCode=400,
191
+ )
192
+
193
+ # Read bytes before the background task starts (request closes after response)
194
+ file_bytes = await file.read()
195
+ filename = file.filename
196
+
197
+ job_store.create_job(job_id)
198
+
199
+ background_tasks.add_task(
200
+ cv_worker.run_cv_processing,
201
+ job_id,
202
+ file_bytes,
203
+ filename,
204
+ callback_url,
205
+ callback_secret,
206
+ )
207
+
208
+ return APIResponse(
209
+ message="CV processing started",
210
+ statusCode=202,
211
+ payload={"job_id": job_id, "status": "PENDING"},
212
+ )
213
+
214
+
215
+ # GET /job-status/{job_id} (Python-side status check)
216
+ @app.get("/job-status/{job_id}", response_model=APIResponse)
217
+ def get_job_status(job_id: str):
218
+ """
219
+ Returns the current status of a CV processing job from the in-memory store.
220
+ Primarily useful for debugging; Java tracks state authoritatively in its DB.
221
+ """
222
+ job = job_store.get_job(job_id)
223
+ if job is None:
224
+ return APIResponse(message="Job not found", statusCode=404)
225
+
226
+ return APIResponse(
227
+ message=f"Job status: {job['status']}",
228
+ statusCode=200,
229
+ payload={"job_id": job_id, "status": job["status"]},
230
+ )
231
+
232
+
233
+ # POST /match-cv (ORIGINAL — unchanged)
234
+
235
  class MatchCVRequest(BaseModel):
236
  """Request body for POST /match-cv."""
237
  job: JobInput = Field(..., description="Structured job posting to match against.")
requirements.txt CHANGED
@@ -5,6 +5,7 @@ fastapi
5
  uvicorn[standard]
6
  pydantic
7
  python-multipart
 
8
 
9
  # Document parsing
10
  pdfplumber
 
5
  uvicorn[standard]
6
  pydantic
7
  python-multipart
8
+ httpx
9
 
10
  # Document parsing
11
  pdfplumber
services/__pycache__/job_store.cpython-312.pyc ADDED
Binary file (2.29 kB). View file
 
services/__pycache__/workers.cpython-312.pyc ADDED
Binary file (5.62 kB). View file
 
services/job_store.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simple in-memory job store for the async CV processing pipeline.
3
+
4
+ Since the Python service runs as a single process on Hugging Face Spaces,
5
+ an in-memory dict is sufficient. Jobs are keyed by job_id (string UUID).
6
+
7
+ Each entry shape:
8
+ {
9
+ "status": "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED",
10
+ "result": <ProcessorResponse payload dict> | None,
11
+ "error": <str> | None,
12
+ }
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from typing import Optional
19
+
20
+ _lock = threading.Lock()
21
+ _store: dict[str, dict] = {}
22
+
23
+
24
+ def create_job(job_id: str) -> None:
25
+ with _lock:
26
+ _store[job_id] = {"status": "PENDING", "result": None, "error": None}
27
+
28
+
29
+ def set_processing(job_id: str) -> None:
30
+ with _lock:
31
+ if job_id in _store:
32
+ _store[job_id]["status"] = "PROCESSING"
33
+
34
+
35
+ def set_completed(job_id: str, result: dict) -> None:
36
+ with _lock:
37
+ _store[job_id] = {"status": "COMPLETED", "result": result, "error": None}
38
+
39
+
40
+ def set_failed(job_id: str, error: str) -> None:
41
+ with _lock:
42
+ if job_id in _store:
43
+ _store[job_id]["status"] = "FAILED"
44
+ _store[job_id]["error"] = error
45
+ else:
46
+ _store[job_id] = {"status": "FAILED", "result": None, "error": error}
47
+
48
+
49
+ def get_job(job_id: str) -> Optional[dict]:
50
+ with _lock:
51
+ return _store.get(job_id)
services/workers.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import httpx
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from services import job_store
10
+ from services.cv_chunker import chunk_cv
11
+ from services.cv_converter import CVConverter
12
+
13
+ class CvWorker:
14
+ """Handles background tasks for CV processing."""
15
+
16
+ def __init__(self, converter: CVConverter):
17
+ self.converter = converter
18
+
19
+ async def run_cv_processing(
20
+ self,
21
+ job_id: str,
22
+ file_bytes: bytes,
23
+ filename: str,
24
+ callback_url: str,
25
+ callback_secret: str = None
26
+ ) -> None:
27
+ """Background task: process CV then POST result to Java callback endpoint."""
28
+ tmp_path: Optional[Path] = None
29
+ try:
30
+ job_store.set_processing(job_id)
31
+
32
+ start_time = datetime.now(timezone.utc).isoformat()
33
+
34
+ suffix = Path(filename).suffix.lower() if filename else ".pdf"
35
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
36
+ tmp.write(file_bytes)
37
+ tmp_path = Path(tmp.name)
38
+
39
+ conversion = self.converter.convert(tmp_path)
40
+
41
+ if not conversion.success:
42
+ error_msg = conversion.error or "Conversion failed"
43
+ job_store.set_failed(job_id, error_msg)
44
+ await self.post_callback(callback_url, {
45
+ "message": error_msg,
46
+ "statusCode": 422,
47
+ "payload": None,
48
+ }, callback_secret)
49
+ return
50
+
51
+ chunks = chunk_cv(conversion.markdown)
52
+ end_time = datetime.now(timezone.utc).isoformat()
53
+
54
+ payload = {
55
+ "markdown": conversion.markdown,
56
+ "cv_title": chunks["cv_title"],
57
+ "seniority": chunks["seniority"],
58
+ "years_experience": chunks["years_experience"],
59
+ "category": chunks["category"],
60
+ "chunks": {
61
+ "summary": chunks["chunks"]["summary"],
62
+ "contact": chunks["chunks"]["contact"],
63
+ "links": chunks["chunks"]["links"],
64
+ "skills": chunks["chunks"]["skills"],
65
+ "experience": chunks["chunks"]["experience"],
66
+ "education": chunks["chunks"]["education"],
67
+ "projects": chunks["chunks"]["projects"],
68
+ "awards": chunks["chunks"]["awards"],
69
+ },
70
+ "file_type": conversion.file_type,
71
+ "method_used": conversion.method_used,
72
+ "is_scanned": conversion.is_scanned,
73
+ "page_count": conversion.page_count,
74
+ "warnings": conversion.warnings,
75
+ "start_time": start_time,
76
+ "end_time": end_time,
77
+ }
78
+
79
+ job_store.set_completed(job_id, payload)
80
+
81
+ await self.post_callback(callback_url, {
82
+ "message": "CV processed successfully",
83
+ "statusCode": 200,
84
+ "payload": payload,
85
+ }, callback_secret)
86
+
87
+ except Exception as exc:
88
+ error_msg = f"Processing error: {exc}"
89
+ job_store.set_failed(job_id, error_msg)
90
+ await self.post_callback(callback_url, {
91
+ "message": error_msg,
92
+ "statusCode": 500,
93
+ "payload": None,
94
+ }, callback_secret)
95
+
96
+ finally:
97
+ if tmp_path and tmp_path.exists():
98
+ tmp_path.unlink()
99
+
100
+ async def post_callback(self, callback_url: str, body: dict, callback_secret: str = None) -> None:
101
+ """POST the processing result back to Java."""
102
+ try:
103
+ headers = {}
104
+ if callback_secret:
105
+ headers["X-Callback-Secret"] = callback_secret
106
+
107
+ async with httpx.AsyncClient(timeout=30.0) as client:
108
+ response = await client.post(callback_url, json=body, headers=headers)
109
+ response.raise_for_status()
110
+ except Exception as exc:
111
+ # Log but don't crash — Java will see job stay in PROCESSING and can time out
112
+ print(f"[CV-ASYNC] Failed to POST callback to {callback_url}: {exc}")