simbakm commited on
Commit
db9ee96
·
1 Parent(s): 26a0dfc

Add resume parser microservice with local LLM

Browse files
Files changed (4) hide show
  1. Dockerfile +44 -0
  2. download_model.py +46 -0
  3. requirements.txt +11 -0
  4. resume_microservice.py +381 -0
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces
2
+ FROM python:3.11-slim
3
+
4
+ # Create a non-root user
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ # Install system dependencies for llama-cpp-python
12
+ USER root
13
+ RUN apt-get update && apt-get install -y \
14
+ build-essential \
15
+ cmake \
16
+ git \
17
+ curl \
18
+ && rm -rf /var/lib/apt/lists/*
19
+ USER user
20
+
21
+ # Copy requirements and install Python dependencies
22
+ COPY --chown=user requirements.txt .
23
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
24
+
25
+ # Copy application files
26
+ COPY --chown=user resume_microservice.py .
27
+ COPY --chown=user download_model.py .
28
+
29
+ # Create necessary directories
30
+ RUN mkdir -p /tmp/uploads resume_parser/models
31
+
32
+ # Expose port 7860 (required by Hugging Face Spaces)
33
+ EXPOSE 7860
34
+
35
+ # Set environment variables
36
+ ENV FLASK_RUN_HOST=0.0.0.0
37
+ ENV FLASK_RUN_PORT=7860
38
+
39
+ # Health check
40
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=300s --retries=3 \
41
+ CMD curl -f http://localhost:7860/health || exit 1
42
+
43
+ # Start the application
44
+ CMD ["python", "resume_microservice.py"]
download_model.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from huggingface_hub import hf_hub_download
3
+
4
+ def download_model(model_name="qwen"):
5
+ models = {
6
+ "phi3": {
7
+ "repo_id": "microsoft/Phi-3-mini-4k-instruct-gguf",
8
+ "filename": "Phi-3-mini-4k-instruct-q4.gguf"
9
+ },
10
+ "qwen": {
11
+ "repo_id": "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
12
+ "filename": "qwen2.5-1.5b-instruct-q4_k_m.gguf"
13
+ }
14
+ }
15
+
16
+ selected = models.get(model_name, models["qwen"])
17
+ repo_id = selected["repo_id"]
18
+ filename = selected["filename"]
19
+
20
+ # Path relative to the script
21
+ model_dir = os.path.join(os.getcwd(), "resume_parser", "models")
22
+ model_path = os.path.join(model_dir, filename)
23
+
24
+ if not os.path.exists(model_dir):
25
+ os.makedirs(model_dir)
26
+
27
+ if not os.path.exists(model_path):
28
+ print(f"Downloading {filename} from {repo_id}...")
29
+ try:
30
+ hf_hub_download(
31
+ repo_id=repo_id,
32
+ filename=filename,
33
+ local_dir=model_dir,
34
+ local_dir_use_symlinks=False
35
+ )
36
+ print("Download complete!")
37
+ except Exception as e:
38
+ print(f"Error downloading model: {e}")
39
+ else:
40
+ print(f"Model already exists at {model_path}")
41
+
42
+ if __name__ == "__main__":
43
+ import sys
44
+ # Default to Qwen for benchmarking speed
45
+ model_choice = sys.argv[1] if len(sys.argv) > 1 else "qwen"
46
+ download_model(model_choice)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Flask==3.1.3
2
+ llama-cpp-python==0.2.90
3
+ PyPDF2==3.0.1
4
+ docx2txt==0.8
5
+ Werkzeug==3.1.6
6
+ numpy==2.4.2
7
+
8
+
9
+ # to run this micro service open terminal and navigate to the root folder"Resume parser" and run the following commands
10
+ # 1.pip install -r requirements.txt
11
+ # 2. python resume_microservice.py
resume_microservice.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Resume Parser Microservice with Local LLM
3
+ FINAL VERSION - With correct model path
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import tempfile
9
+ from flask import Flask, request, jsonify
10
+ from werkzeug.utils import secure_filename
11
+ import PyPDF2
12
+ import docx2txt
13
+ from llama_cpp import Llama
14
+ import re
15
+ from download_model import download_model
16
+
17
+ app = Flask(__name__)
18
+
19
+
20
+ def ensure_model():
21
+ if not os.path.exists(MODEL_PATH):
22
+ print("📥 Downloading model...")
23
+ download_model("qwen")
24
+
25
+ if not os.path.exists(MODEL_PATH):
26
+ raise RuntimeError("Model download failed")
27
+
28
+ print("✅ Model ready")
29
+
30
+
31
+
32
+
33
+ # Configuration
34
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
35
+ app.config['UPLOAD_EXTENSIONS'] = ['.pdf', '.docx', '.doc', '.txt']
36
+
37
+ # ABSOLUTE PATH - Use the exact path from your diagnostic
38
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
39
+ MODEL_PATH = os.path.join(BASE_DIR, 'resume_parser', 'models', 'qwen2.5-1.5b-instruct-q4_k_m.gguf')
40
+
41
+ print("\n" + "="*60)
42
+ print("🚀 RESUME PARSER MICROSERVICE")
43
+ print("="*60)
44
+ print(f"📂 Base directory: {BASE_DIR}")
45
+ print(f"📁 Model path: {MODEL_PATH}")
46
+
47
+ # download model if not present
48
+ ensure_model()
49
+
50
+ # Verify model exists
51
+ if os.path.exists(MODEL_PATH):
52
+ model_size = os.path.getsize(MODEL_PATH) / (1024 * 1024) # Convert to MB
53
+ print(f"✅ Model found! Size: {model_size:.1f} MB")
54
+ else:
55
+ print(f"❌ ERROR: Model not found at: {MODEL_PATH}")
56
+ print("Please check the path and try again.")
57
+ exit(1)
58
+
59
+ print("\n⏳ Loading model (this may take 30-60 seconds)...")
60
+
61
+ try:
62
+ # Initialize the LLM with explicit CPU settings
63
+ llm = Llama(
64
+ model_path=MODEL_PATH,
65
+ n_ctx=2048, # Context window
66
+ n_threads=6, # Adjust based on your CPU
67
+ n_gpu_layers=0, # Force CPU only
68
+ verbose=False, # Set to True for debugging
69
+ use_mmap=True, # Use memory mapping for faster loading
70
+ use_mlock=False # Don't lock memory
71
+ )
72
+ print("✅ Model loaded successfully!")
73
+ MODEL_LOADED = True
74
+ except Exception as e:
75
+ print(f"❌ Failed to load model: {e}")
76
+ print("\nTroubleshooting tips:")
77
+ print("1. Make sure you have enough RAM (at least 4GB free)")
78
+ print("2. Try setting verbose=True to see detailed error")
79
+ print("3. Check if the model file is corrupted")
80
+ MODEL_LOADED = False
81
+ llm = None
82
+
83
+ print("="*60 + "\n")
84
+
85
+ def extract_text_from_pdf(file_path):
86
+ """Extract text from PDF file"""
87
+ text = ""
88
+ try:
89
+ with open(file_path, 'rb') as file:
90
+ pdf_reader = PyPDF2.PdfReader(file)
91
+ for page in pdf_reader.pages:
92
+ page_text = page.extract_text()
93
+ if page_text:
94
+ text += page_text + "\n"
95
+ except Exception as e:
96
+ print(f"PDF extraction error: {e}")
97
+ return text
98
+
99
+ def extract_text_from_docx(file_path):
100
+ """Extract text from DOCX file"""
101
+ try:
102
+ return docx2txt.process(file_path)
103
+ except Exception as e:
104
+ print(f"DOCX extraction error: {e}")
105
+ return ""
106
+
107
+ def extract_text_from_txt(file_path):
108
+ """Extract text from TXT file"""
109
+ try:
110
+ with open(file_path, 'r', encoding='utf-8') as file:
111
+ return file.read()
112
+ except Exception as e:
113
+ print(f"TXT extraction error: {e}")
114
+ return ""
115
+
116
+ def extract_text(file_path):
117
+ """Extract text based on file extension"""
118
+ ext = os.path.splitext(file_path)[1].lower()
119
+
120
+ if ext == '.pdf':
121
+ return extract_text_from_pdf(file_path)
122
+ elif ext in ['.docx', '.doc']:
123
+ return extract_text_from_docx(file_path)
124
+ elif ext == '.txt':
125
+ return extract_text_from_txt(file_path)
126
+ else:
127
+ return ""
128
+
129
+ def clean_text(text):
130
+ """Clean and normalize extracted text"""
131
+ # Remove extra whitespace
132
+ text = re.sub(r'\s+', ' ', text)
133
+ # Remove special characters but keep basic punctuation
134
+ text = re.sub(r'[^\w\s\.\,\-\@\+]', '', text)
135
+ return text.strip()
136
+
137
+
138
+ def parse_with_llm(text):
139
+ """Use local LLM to extract structured information"""
140
+
141
+ if llm is None:
142
+ return {"error": "LLM model not loaded"}
143
+
144
+ # Truncate text to fit model's context window
145
+ max_chars = 2000 # Increased from 1500
146
+ if len(text) > max_chars:
147
+ text = text[:max_chars] + "..."
148
+
149
+ # Create a structured prompt for the LLM
150
+ prompt = f"""Extract the following information from this resume and return COMPLETE valid JSON.
151
+ The candidate's name is typically written in ALL CAPS and appears near the top or bottom of the resume.
152
+ Ignore names mentioned in references or supervisors - look for the actual candidate's name in ALL CAPS.
153
+
154
+ Required fields:
155
+ - name: full name of the candidate (look for ALL CAPS name like "CLEOPATRA MAKUDO")
156
+ - email: email address
157
+ - phone: phone number
158
+ - skills: list of technical skills (exclude hobbies like "watching soccer")
159
+ - education: list of education entries with qualification, institution, year
160
+ - experience: list of work experiences with job title, company, duration
161
+ - objectives: career objectives if mentioned
162
+ - location: address or location
163
+ - languages: list of languages known
164
+
165
+ Resume text:
166
+ {text}
167
+
168
+ Return a COMPLETE JSON object with ALL fields. Do not truncate. The JSON must be properly closed with matching braces."""
169
+
170
+ try:
171
+ # Generate response from local LLM with even larger max_tokens
172
+ response = llm(
173
+ prompt,
174
+ max_tokens=3072, # Increased from 2048 to 3072
175
+ temperature=0.1,
176
+ stop=None, # Remove stop to prevent early truncation
177
+ echo=False
178
+ )
179
+
180
+ # Extract JSON from response
181
+ output = response['choices'][0]['text'].strip()
182
+
183
+ # Try to find and complete JSON
184
+ try:
185
+ # Find the start of JSON
186
+ json_start = output.find('{')
187
+ if json_start == -1:
188
+ raise ValueError("No JSON object found")
189
+
190
+ # Extract from first { to end
191
+ json_str = output[json_start:]
192
+
193
+ # Count braces to ensure completion
194
+ brace_count = 0
195
+ in_string = False
196
+ escape_next = False
197
+ last_valid_end = 0
198
+
199
+ for i, char in enumerate(json_str):
200
+ if escape_next:
201
+ escape_next = False
202
+ continue
203
+
204
+ if char == '\\' and in_string:
205
+ escape_next = True
206
+ continue
207
+
208
+ if char == '"' and not escape_next:
209
+ in_string = not in_string
210
+ continue
211
+
212
+ if not in_string:
213
+ if char == '{':
214
+ brace_count += 1
215
+ elif char == '}':
216
+ brace_count -= 1
217
+ if brace_count == 0:
218
+ last_valid_end = i + 1
219
+
220
+ # If we have a complete JSON object
221
+ if last_valid_end > 0:
222
+ json_str = json_str[:last_valid_end]
223
+
224
+ # Clean up common JSON issues
225
+ json_str = re.sub(r',\s*}', '}', json_str) # Remove trailing commas
226
+ json_str = re.sub(r',\s*]', ']', json_str) # Remove trailing commas in arrays
227
+ json_str = re.sub(r'}\s*{', '},{', json_str) # Fix multiple objects
228
+
229
+ # Parse the JSON
230
+ parsed_data = json.loads(json_str)
231
+
232
+ # Post-process to clean up extracted data
233
+ if 'skills' in parsed_data and isinstance(parsed_data['skills'], list):
234
+ # Remove hobbies from skills
235
+ hobbies = ['watching soccer', 'choir', 'soccer', 'football', 'singing']
236
+ parsed_data['skills'] = [s for s in parsed_data['skills']
237
+ if s.lower() not in hobbies]
238
+
239
+ return parsed_data
240
+
241
+ except json.JSONDecodeError as e:
242
+ # If parsing fails, return helpful error with the raw output
243
+ return {
244
+ "error": "JSON parsing failed",
245
+ "error_details": str(e),
246
+ "raw_extraction": output[:1000] # Return more of the raw output
247
+ }
248
+
249
+ except Exception as e:
250
+ return {"error": f"LLM inference failed: {str(e)}"}
251
+
252
+ @app.route('/health', methods=['GET'])
253
+ def health_check():
254
+ """Health check endpoint"""
255
+ return jsonify({
256
+ 'status': 'healthy' if MODEL_LOADED else 'degraded',
257
+ 'model_loaded': MODEL_LOADED,
258
+ 'model_path': MODEL_PATH,
259
+ 'model_size_mb': round(os.path.getsize(MODEL_PATH) / (1024 * 1024), 1) if os.path.exists(MODEL_PATH) else 0,
260
+ 'message': 'Resume parser microservice is running'
261
+ })
262
+
263
+ @app.route('/parse', methods=['POST'])
264
+ def parse_resume():
265
+ """Parse a resume file"""
266
+ if not MODEL_LOADED:
267
+ return jsonify({'error': 'Model not loaded. Please check server logs.'}), 503
268
+
269
+ if 'file' not in request.files:
270
+ return jsonify({'error': 'No file provided'}), 400
271
+
272
+ file = request.files['file']
273
+
274
+ if file.filename == '':
275
+ return jsonify({'error': 'Empty filename'}), 400
276
+
277
+ # Check file extension
278
+ ext = os.path.splitext(file.filename)[1].lower()
279
+ if ext not in app.config['UPLOAD_EXTENSIONS']:
280
+ return jsonify({
281
+ 'error': f'Unsupported file type. Supported: {app.config["UPLOAD_EXTENSIONS"]}'
282
+ }), 400
283
+
284
+ # Save file temporarily
285
+ filename = secure_filename(file.filename)
286
+ temp_dir = tempfile.mkdtemp()
287
+ file_path = os.path.join(temp_dir, filename)
288
+
289
+ try:
290
+ file.save(file_path)
291
+
292
+ # Extract text from file
293
+ raw_text = extract_text(file_path)
294
+ if not raw_text:
295
+ return jsonify({'error': 'Could not extract text from file'}), 400
296
+
297
+ # Clean the extracted text
298
+ cleaned_text = clean_text(raw_text)
299
+
300
+ # Parse with local LLM
301
+ parsed_data = parse_with_llm(cleaned_text)
302
+
303
+ # Add metadata
304
+ response = {
305
+ 'success': True,
306
+ 'filename': filename,
307
+ 'file_type': ext,
308
+ 'data': parsed_data,
309
+ 'text_length': len(cleaned_text)
310
+ }
311
+
312
+ return jsonify(response)
313
+
314
+ except Exception as e:
315
+ return jsonify({'error': str(e)}), 500
316
+
317
+ finally:
318
+ # Clean up temporary files
319
+ try:
320
+ os.remove(file_path)
321
+ os.rmdir(temp_dir)
322
+ except:
323
+ pass
324
+
325
+ @app.route('/parse-text', methods=['POST'])
326
+ def parse_text():
327
+ """Parse raw text content"""
328
+ if not MODEL_LOADED:
329
+ return jsonify({'error': 'Model not loaded. Please check server logs.'}), 503
330
+
331
+ data = request.get_json()
332
+
333
+ if not data or 'text' not in data:
334
+ return jsonify({'error': 'No text provided'}), 400
335
+
336
+ text = data['text']
337
+ cleaned_text = clean_text(text)
338
+ parsed_data = parse_with_llm(cleaned_text)
339
+
340
+ return jsonify({
341
+ 'success': True,
342
+ 'data': parsed_data,
343
+ 'text_length': len(cleaned_text)
344
+ })
345
+
346
+ @app.route('/debug', methods=['GET'])
347
+ def debug_info():
348
+ """Debug endpoint to check system info"""
349
+ import sys
350
+ import platform
351
+
352
+ return jsonify({
353
+ 'python_version': sys.version,
354
+ 'platform': platform.platform(),
355
+ 'current_dir': os.getcwd(),
356
+ 'model_path': MODEL_PATH,
357
+ 'model_exists': os.path.exists(MODEL_PATH),
358
+ 'model_loaded': MODEL_LOADED,
359
+ 'files_in_dir': os.listdir('.')[:10] # First 10 files
360
+ })
361
+
362
+ if __name__ == '__main__':
363
+ if not MODEL_LOADED:
364
+ print("\n⚠️ WARNING: Model failed to load!")
365
+ print(" Check the debug endpoint at http://localhost:5000/debug")
366
+ print(" Common issues:")
367
+ print(" - Insufficient RAM (need at least 4GB free)")
368
+ print(" - Corrupted model file")
369
+ print(" - Missing C++ runtime")
370
+ else:
371
+ print("\n✅ Server ready to accept requests!")
372
+
373
+ print("\n📡 Server starting on http://localhost:5000")
374
+ print("📝 Available endpoints:")
375
+ print(" - GET /health - Health check")
376
+ print(" - GET /debug - Debug information")
377
+ print(" - POST /parse - Upload resume file")
378
+ print(" - POST /parse-text - Send raw text")
379
+ print("\n" + "="*60)
380
+
381
+ app.run(host='0.0.0.0', port=7860, debug=False)