vector / app.py
karadumandilara's picture
Upload 4 files
1a63056 verified
Raw
History Blame Contribute Delete
6.06 kB
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
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_NAME}
@app.post("/extract_profile", response_model=ExtractResponse)
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
}