Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, HTTPException | |
| from pydantic import BaseModel, Field | |
| from transformers import AutoTokenizer, AutoModelForCausalLM | |
| import torch | |
| import json | |
| import re | |
| import os | |
| MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct") | |
| MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "256")) | |
| app = FastAPI(title="Qwen Mini Extractor", version="3.1.0") | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_NAME, | |
| torch_dtype=torch.float32, | |
| low_cpu_mem_usage=True, | |
| trust_remote_code=True | |
| ) | |
| model.eval() | |
| SYSTEM_PROMPT = """ | |
| You extract structured candidate or job information from text. | |
| Return only valid JSON. | |
| No markdown. | |
| No explanations. | |
| Do not invent information. | |
| If a field is missing, use empty string or empty list. | |
| All list fields must contain strings only. | |
| """ | |
| def normalize_text(text: str) -> str: | |
| text = text.replace("\r\n", "\n").replace("\r", "\n") | |
| text = re.sub(r"^\s*Text:\s*", "", text, flags=re.IGNORECASE) | |
| text = re.sub(r"\n{3,}", "\n\n", text) | |
| return text.strip() | |
| def build_user_prompt(text: str, document_type: str) -> str: | |
| return f""" | |
| Document type: {document_type} | |
| Return ONLY this JSON schema: | |
| {{ | |
| "job_title": "", | |
| "skills": [], | |
| "experiences": [], | |
| "location": "", | |
| "summary": "" | |
| }} | |
| Rules: | |
| - job_title = current role or most relevant target role | |
| - if job_title is missing, use the most recent experience title | |
| - experiences = past experience titles only, as strings, ordered from most recent to oldest when possible | |
| - skills = concise list of professional skills | |
| - location = main location if present | |
| - summary = very short summary, max 25 words | |
| - no nested objects | |
| - no extra keys | |
| - no text before or after JSON | |
| - do not use null | |
| - if unknown, use "" or [] | |
| Text: | |
| {text} | |
| """ | |
| def extract_json_block(text: str) -> dict: | |
| text = text.strip() | |
| fence_match = re.search(r"```json\s*(\{.*?\})\s*```", text, flags=re.DOTALL | re.IGNORECASE) | |
| if fence_match: | |
| return json.loads(fence_match.group(1)) | |
| fence_match_generic = re.search(r"```\s*(\{.*?\})\s*```", text, flags=re.DOTALL) | |
| if fence_match_generic: | |
| return json.loads(fence_match_generic.group(1)) | |
| start = text.find("{") | |
| if start == -1: | |
| raise ValueError("No JSON object found") | |
| depth = 0 | |
| in_string = False | |
| escape = False | |
| for i in range(start, len(text)): | |
| ch = text[i] | |
| if in_string: | |
| if escape: | |
| escape = False | |
| elif ch == "\\": | |
| escape = True | |
| elif ch == '"': | |
| in_string = False | |
| continue | |
| if ch == '"': | |
| in_string = True | |
| elif ch == "{": | |
| depth += 1 | |
| elif ch == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| return json.loads(text[start:i + 1]) | |
| raise ValueError("No balanced JSON object found") | |
| def to_string_list(value) -> list[str]: | |
| if value is None: | |
| return [] | |
| if isinstance(value, list): | |
| out = [] | |
| for v in value: | |
| if isinstance(v, str): | |
| s = v.strip() | |
| if s: | |
| out.append(s) | |
| elif v is not None: | |
| s = str(v).strip() | |
| if s: | |
| out.append(s) | |
| return list(dict.fromkeys(out)) | |
| if isinstance(value, str): | |
| value = value.strip() | |
| return [value] if value else [] | |
| s = str(value).strip() | |
| return [s] if s else [] | |
| def clean_scalar(value) -> str: | |
| if value is None: | |
| return "" | |
| s = str(value).strip() | |
| invalid_values = { | |
| "n/a", | |
| "na", | |
| "none", | |
| "null", | |
| "unknown", | |
| "not specified", | |
| "not provided", | |
| "-" | |
| } | |
| if s.lower() in invalid_values: | |
| return "" | |
| return s | |
| def normalize_profile(profile: dict) -> dict: | |
| if not isinstance(profile, dict): | |
| profile = {} | |
| job_title = clean_scalar(profile.get("job_title", "")) | |
| skills = to_string_list(profile.get("skills", [])) | |
| experiences = to_string_list(profile.get("experiences", [])) | |
| location = clean_scalar(profile.get("location", "")) | |
| summary = clean_scalar(profile.get("summary", "")) | |
| if not job_title and experiences: | |
| job_title = experiences[0].strip() | |
| return { | |
| "job_title": job_title, | |
| "skills": skills, | |
| "experiences": experiences, | |
| "location": location, | |
| "summary": summary, | |
| } | |
| class ExtractRequest(BaseModel): | |
| text: str = Field(..., min_length=1) | |
| document_type: str = "generic" | |
| class ExtractResponse(BaseModel): | |
| profile: dict | |
| model: str | |
| raw_output: str | None = None | |
| def health(): | |
| return {"status": "ok", "model": MODEL_NAME} | |
| def extract_profile(payload: ExtractRequest): | |
| text = normalize_text(payload.text) | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": build_user_prompt(text, payload.document_type)} | |
| ] | |
| prompt = tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True | |
| ) | |
| inputs = tokenizer(prompt, return_tensors="pt") | |
| with torch.no_grad(): | |
| outputs = model.generate( | |
| **inputs, | |
| max_new_tokens=MAX_NEW_TOKENS, | |
| do_sample=False | |
| ) | |
| generated = tokenizer.decode( | |
| outputs[0][inputs["input_ids"].shape[1]:], | |
| skip_special_tokens=True | |
| ).strip() | |
| try: | |
| raw_profile = extract_json_block(generated) | |
| profile = normalize_profile(raw_profile) | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=422, | |
| detail={ | |
| "error": str(e), | |
| "raw_output": generated | |
| } | |
| ) | |
| return { | |
| "profile": profile, | |
| "model": MODEL_NAME, | |
| "raw_output": generated | |
| } |