Spaces:
Sleeping
Sleeping
| """ | |
| app.py | |
| ====== | |
| Smart Recruiter — Resume Parser | |
| HuggingFace Space Deployment | |
| Runs with: uvicorn app:app --host 0.0.0.0 --port 7860 --workers 1 | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import re | |
| from contextlib import asynccontextmanager | |
| import torch | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import JSONResponse | |
| from peft import PeftModel | |
| from pydantic import BaseModel | |
| from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| ADAPTER_REPO = "DarkSting/resume_parser" | |
| BASE_MODEL = "google/gemma-4-E2B-it" | |
| MAX_NEW_TOKENS = 800 | |
| SYSTEM_PROMPT = """You are an expert resume parser. Extract structured information from resume text and return ONLY valid JSON with no extra text, no markdown, no explanation. | |
| Always follow this exact schema. Use null for missing fields and [] for missing lists: | |
| { | |
| "name": "Full Name or null", | |
| "email": "email@example.com or null", | |
| "phone": "phone number or null", | |
| "skills": ["skill1", "skill2"], | |
| "programming_languages": ["Python", "JavaScript"], | |
| "experience": [ | |
| { | |
| "job_title": "Job Title", | |
| "company": "Company Name", | |
| "start_date": "MM/YYYY or MM/DD/YYYY or null", | |
| "end_date": "MM/YYYY or MM/DD/YYYY or Present or null", | |
| "responsibilities": ["responsibility1", "responsibility2"] | |
| } | |
| ], | |
| "total_experience_years": 0.0, | |
| "education": [ | |
| { | |
| "degree": "Degree Name", | |
| "field": "Field of Study or null", | |
| "institution": "Institution Name or null", | |
| "year": 2020 | |
| } | |
| ], | |
| "certifications": ["cert1", "cert2"], | |
| "projects": ["Project Name 1", "Project Name 2"], | |
| "publications": ["publication1"], | |
| "achievements": ["achievement1"], | |
| "summary": "Brief professional summary or null" | |
| }""" | |
| # --------------------------------------------------------------------------- | |
| # Global model state | |
| # --------------------------------------------------------------------------- | |
| model = None | |
| tokenizer = None | |
| load_error = None | |
| # --------------------------------------------------------------------------- | |
| # Model loading | |
| # --------------------------------------------------------------------------- | |
| def load_model(): | |
| global model, tokenizer, load_error | |
| logger.info("Loading base model: %s", BASE_MODEL) | |
| logger.info("Loading adapter : %s", ADAPTER_REPO) | |
| try: | |
| if torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 8: | |
| torch_dtype = torch.bfloat16 | |
| else: | |
| torch_dtype = torch.float16 | |
| model_kwargs = dict( | |
| dtype=torch_dtype, | |
| device_map="auto", | |
| ) | |
| model_kwargs["quantization_config"] = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_quant_type="nf4", | |
| bnb_4bit_compute_dtype=torch_dtype, | |
| bnb_4bit_quant_storage=torch_dtype, | |
| ) | |
| logger.info("Loading base model ...") | |
| base_model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, **model_kwargs) | |
| logger.info("Loading LoRA adapter ...") | |
| model = PeftModel.from_pretrained(base_model, ADAPTER_REPO) | |
| model.eval() | |
| tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL) | |
| logger.info("Model ready on device: %s", model.device) | |
| except Exception as e: | |
| load_error = str(e) | |
| logger.exception("Failed to load model: %s", e) | |
| # --------------------------------------------------------------------------- | |
| # Lifespan — load model once at startup | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app: FastAPI): | |
| load_model() | |
| yield | |
| # --------------------------------------------------------------------------- | |
| # FastAPI app | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="Smart Recruiter — Resume Parser", | |
| description="Fine-tuned Gemma-4-E2B-it resume parser. POST raw resume text, get structured JSON.", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Schemas | |
| # --------------------------------------------------------------------------- | |
| class AnalyzeRequest(BaseModel): | |
| content: str | |
| # --------------------------------------------------------------------------- | |
| # Inference | |
| # --------------------------------------------------------------------------- | |
| def parse_resume(resume_text: str) -> dict: | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"Parse this resume and return ONLY a JSON object:\n\n" | |
| f"RESUME:\n{resume_text[:3000]}\n\n" | |
| f"Return ONLY the JSON object, nothing else." | |
| ), | |
| }, | |
| ] | |
| text = tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| inputs = tokenizer(text=text, return_tensors="pt").to(model.device) | |
| with torch.no_grad(): | |
| output = model.generate( | |
| **inputs, | |
| max_new_tokens=MAX_NEW_TOKENS, | |
| do_sample=False, | |
| temperature=None, | |
| top_p=None, | |
| repetition_penalty=1.1, | |
| pad_token_id=tokenizer.eos_token_id, | |
| eos_token_id=tokenizer.eos_token_id, | |
| ) | |
| generated = output[0][inputs["input_ids"].shape[1]:] | |
| response = tokenizer.decode(generated, skip_special_tokens=True).strip() | |
| response = re.sub(r"^```json\s*", "", response) | |
| response = re.sub(r"```$", "", response).strip() | |
| try: | |
| return json.loads(response) | |
| except json.JSONDecodeError: | |
| match = re.search(r"\{[\s\S]*\}", response) | |
| if match: | |
| return json.loads(match.group()) | |
| raise ValueError(f"Model output was not valid JSON:\n{response[:300]}") | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| def health(): | |
| return { | |
| "status": "ready" if model else "degraded", | |
| "model_loaded": model is not None, | |
| "base_model": BASE_MODEL, | |
| "adapter_repo": ADAPTER_REPO, | |
| "load_error": load_error, | |
| "device": str(model.device) if model else None, | |
| } | |
| def analyze(request: AnalyzeRequest): | |
| if model is None: | |
| raise HTTPException( | |
| status_code=503, | |
| detail=f"Model not loaded. Check /health. Error: {load_error}", | |
| ) | |
| content = request.content.strip() | |
| if not content: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="'content' field is empty. Send raw resume text.", | |
| ) | |
| logger.info("Parsing resume (%d chars) ...", len(content)) | |
| try: | |
| result = parse_resume(content) | |
| logger.info("Parse successful. Skills: %s", result.get("skills", [])) | |
| return JSONResponse(content=result) | |
| except ValueError as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| except Exception as e: | |
| logger.exception("Inference error: %s", e) | |
| raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}") |