Files
raulgupta 89ca9ad647 Initial commit: matchmaking-v2 on-demand multi-board job-search agent
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
2026-06-18 19:22:03 +05:30

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))