On-demand Scout service (replaces the nightly aggregator): - FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks + Redis-stream worker matching the sibling-service pattern) - run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit + LinkedIn), city-filtered at the board, cheapest-per-result first - per-board adapters + normalizers -> ScoutJob with rich read-only details and offsite apply links; recall mode + MVQ guard - result cache for dev replay ($0); per-board cost knobs; retry-on-5xx - research/: engine design, board cost economics, India-actor shortlist, POC
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
"""
|
|
parse_resume.py — turn a résumé PDF into a structured profile using a VISION model.
|
|
|
|
This reuses the proven pattern from the resume-builder service
|
|
(growqr-demo/resume-builder/app/services/parse_service.py): render each PDF page to a PNG,
|
|
send the images to gpt-4o-mini with JSON mode, merge the pages. Vision (not text extraction)
|
|
because it preserves layout — columns, headers, contact blocks — that plain text destroys.
|
|
|
|
The output `profile` dict is the SINGLE SOURCE OF TRUTH the apply agent uses to fill the form,
|
|
so identity and experience come from the real résumé, never from guesses.
|
|
|
|
Standalone usage:
|
|
python parse_resume.py # parses ./Resume.pdf and prints the profile
|
|
Importable:
|
|
from parse_resume import parse_resume_to_profile
|
|
profile = await parse_resume_to_profile("Resume.pdf")
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
import fitz # PyMuPDF
|
|
from dotenv import load_dotenv
|
|
from openai import AsyncOpenAI
|
|
|
|
load_dotenv()
|
|
|
|
VISION_MODEL = "gpt-4o-mini"
|
|
|
|
SYSTEM_PROMPT = """You are a precise résumé parser. You are given an image of ONE page of a
|
|
candidate's résumé. Extract ONLY what is actually present — never invent or infer values that
|
|
are not visibly written. Return a JSON object with exactly these keys:
|
|
|
|
{
|
|
"full_name": string | null,
|
|
"email": string | null,
|
|
"phone": string | null,
|
|
"location": string | null, // city/region as written
|
|
"linkedin_url": string | null,
|
|
"github_url": string | null,
|
|
"portfolio_url": string | null,
|
|
"current_title": string | null, // most recent / current role title
|
|
"skills": [string], // technical skills, as written
|
|
"experience": [ // every role visible on THIS page
|
|
{"company": string, "title": string, "start_date": "YYYY-MM" | null,
|
|
"end_date": "YYYY-MM" | null, "is_current": boolean}
|
|
],
|
|
"education": [
|
|
{"institution": string, "degree": string, "field_of_study": string | null,
|
|
"end_date": "YYYY-MM" | null}
|
|
]
|
|
}
|
|
|
|
If a field is not on this page, use null (or [] for lists). Output ONLY the JSON object."""
|
|
|
|
|
|
def _pdf_to_png_b64(pdf_path: str, dpi: int = 200) -> list[str]:
|
|
"""Render each PDF page to a base64-encoded PNG (same approach as resume-builder)."""
|
|
doc = fitz.open(pdf_path)
|
|
images: list[str] = []
|
|
for page in doc:
|
|
pix = page.get_pixmap(dpi=dpi)
|
|
images.append(base64.b64encode(pix.tobytes("png")).decode("utf-8"))
|
|
doc.close()
|
|
return images
|
|
|
|
|
|
async def _parse_page(client: AsyncOpenAI, img_b64: str, page_num: int, total: int) -> dict[str, Any]:
|
|
resp = await client.chat.completions.create(
|
|
model=VISION_MODEL,
|
|
messages=[
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": f"Résumé page {page_num} of {total}."},
|
|
{"type": "image_url",
|
|
"image_url": {"url": f"data:image/png;base64,{img_b64}", "detail": "high"}},
|
|
],
|
|
},
|
|
],
|
|
temperature=0.1,
|
|
max_tokens=4000,
|
|
response_format={"type": "json_object"},
|
|
)
|
|
try:
|
|
return json.loads(resp.choices[0].message.content or "{}")
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def _first(*vals):
|
|
for v in vals:
|
|
if v:
|
|
return v
|
|
return None
|
|
|
|
|
|
def _years_experience(experience: list[dict]) -> int | None:
|
|
"""Rough total experience: earliest start date -> today (or latest end)."""
|
|
starts = [e.get("start_date") for e in experience if e.get("start_date")]
|
|
if not starts:
|
|
return None
|
|
earliest = min(starts) # "YYYY-MM" strings sort chronologically
|
|
try:
|
|
y, m = (int(x) for x in earliest.split("-")[:2])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
today = date.today()
|
|
months = (today.year - y) * 12 + (today.month - m)
|
|
return max(0, round(months / 12))
|
|
|
|
|
|
def _merge(pages: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Merge per-page results into one profile; first non-empty wins for scalars."""
|
|
skills: list[str] = []
|
|
experience: list[dict] = []
|
|
education: list[dict] = []
|
|
for p in pages:
|
|
for s in p.get("skills") or []:
|
|
if s and s not in skills:
|
|
skills.append(s)
|
|
experience.extend(p.get("experience") or [])
|
|
education.extend(p.get("education") or [])
|
|
|
|
profile = {
|
|
"full_name": _first(*[p.get("full_name") for p in pages]),
|
|
"email": _first(*[p.get("email") for p in pages]),
|
|
"phone": _first(*[p.get("phone") for p in pages]),
|
|
"location": _first(*[p.get("location") for p in pages]),
|
|
"linkedin_url": _first(*[p.get("linkedin_url") for p in pages]),
|
|
"github_url": _first(*[p.get("github_url") for p in pages]),
|
|
"portfolio_url": _first(*[p.get("portfolio_url") for p in pages]),
|
|
"current_title": _first(*[p.get("current_title") for p in pages]),
|
|
"skills": skills,
|
|
"experience": experience,
|
|
"education": education,
|
|
}
|
|
profile["years_experience"] = _years_experience(experience)
|
|
return profile
|
|
|
|
|
|
async def parse_resume_to_profile(pdf_path: str = "Resume.pdf") -> dict[str, Any]:
|
|
if not os.path.exists(pdf_path):
|
|
raise FileNotFoundError(f"résumé not found: {pdf_path}")
|
|
if not os.environ.get("OPENAI_API_KEY"):
|
|
raise RuntimeError("OPENAI_API_KEY not set (put it in .env)")
|
|
|
|
images = _pdf_to_png_b64(pdf_path)
|
|
client = AsyncOpenAI()
|
|
pages = await asyncio.gather(
|
|
*[_parse_page(client, img, i + 1, len(images)) for i, img in enumerate(images)]
|
|
)
|
|
return _merge(list(pages))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
profile = asyncio.run(parse_resume_to_profile())
|
|
print(json.dumps(profile, indent=2, ensure_ascii=False))
|