diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..6b19d6a8560d8137e8d724fcf0b95e8dbe02697d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libharfbuzz0b \ + libpangoft2-1.0-0 \ + libglib2.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + shared-mime-info \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +RUN mkdir -p output + +EXPOSE 7860 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 5fc13a7b6be1cdb7450a364f36498fce31f26958..d284f33f38c39c41ea4638dcae55413687a1104f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,68 @@ --- -title: Draftme -emoji: 💻 -colorFrom: indigo -colorTo: blue -sdk: gradio -sdk_version: 6.18.0 -python_version: '3.13' -app_file: app.py +title: DraftMe +sdk: docker +app_port: 7860 pinned: false -short_description: Upload CV and get job-optimized resume under 1 minute --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# DraftMe + +DraftMe is an AI resume tailoring tool. Upload a PDF resume, paste a job description, choose a model, and generate a targeted one-page resume PDF. + +The app extracts structured information from the resume, parses the job posting, optimizes the resume against the role, validates the result, and renders the final HTML to PDF. + +## What It Does + +- Extracts resume content from uploaded PDFs with PyMuPDF +- Parses job postings into title, company, requirements, keywords, and summary +- Uses Modal-hosted vLLM endpoints for LLM inference +- Lets users choose between Qwen and NVIDIA Nemotron backends +- Runs validation filters for structure, length, hallucination risk, and keyword coverage +- Shows live workflow telemetry while the resume is being generated +- Renders the optimized resume to PDF with WeasyPrint + +## Models + +DraftMe currently supports: + +- Qwen 3.5 27B FP8 +- NVIDIA Nemotron 3 Nano 30B BF16 + +Both models are served through OpenAI-compatible Modal endpoints. + +## Stack + +- Python 3.11 +- Gradio Server with custom HTML, CSS, and JavaScript UI +- FastAPI / ASGI +- Pydantic and Pydantic AI +- OpenAI SDK for Modal vLLM calls +- PyMuPDF for PDF text extraction +- WeasyPrint for PDF rendering +- Jinja2 resume templates + +## Local Run + +```bash +uv run --with-requirements requirements.txt uvicorn app:app --host 127.0.0.1 --port 8801 +``` + +Then open: + +```text +http://127.0.0.1:8801 +``` + +## Hugging Face Spaces + +This project is configured as a Docker Space because it serves a custom ASGI app instead of a standard Gradio Blocks interface. + +The container starts with: + +```bash +uvicorn app:app --host 0.0.0.0 --port 7860 +``` + +## Notes + +Generated PDFs are written to the local `output/` directory and are ignored during Space uploads. diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..996cc53a04100d841ab138a2f0fe52fb593094de --- /dev/null +++ b/agents/__init__.py @@ -0,0 +1,2 @@ +"""LLM-backed pipeline agents.""" + diff --git a/agents/__pycache__/__init__.cpython-311.pyc b/agents/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c8d0f242f16dfd0386d8664e27fa3360bc937b4 Binary files /dev/null and b/agents/__pycache__/__init__.cpython-311.pyc differ diff --git a/agents/__pycache__/__init__.cpython-312.pyc b/agents/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9b0192c9f7d425161094bad563e7b0ea79353a2 Binary files /dev/null and b/agents/__pycache__/__init__.cpython-312.pyc differ diff --git a/agents/__pycache__/ai_generated_detector.cpython-311.pyc b/agents/__pycache__/ai_generated_detector.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e7e43966a19629ae5c047a44a02362d9d5504559 Binary files /dev/null and b/agents/__pycache__/ai_generated_detector.cpython-311.pyc differ diff --git a/agents/__pycache__/ai_generated_detector.cpython-312.pyc b/agents/__pycache__/ai_generated_detector.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2c30d06e0dcd004004d8080740e46b365afbc85 Binary files /dev/null and b/agents/__pycache__/ai_generated_detector.cpython-312.pyc differ diff --git a/agents/__pycache__/combined_reviewer.cpython-311.pyc b/agents/__pycache__/combined_reviewer.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8fde97fa910e12cacd1b2987f82c9d07b080772 Binary files /dev/null and b/agents/__pycache__/combined_reviewer.cpython-311.pyc differ diff --git a/agents/__pycache__/combined_reviewer.cpython-312.pyc b/agents/__pycache__/combined_reviewer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44de76007b81e07da1e771f9212d20d513589268 Binary files /dev/null and b/agents/__pycache__/combined_reviewer.cpython-312.pyc differ diff --git a/agents/__pycache__/extractor.cpython-311.pyc b/agents/__pycache__/extractor.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..052fdac070b2ff6b675d4a1d761efb574334a301 Binary files /dev/null and b/agents/__pycache__/extractor.cpython-311.pyc differ diff --git a/agents/__pycache__/extractor.cpython-312.pyc b/agents/__pycache__/extractor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d2fe557f932658efcdff273de40c6de0507abbc Binary files /dev/null and b/agents/__pycache__/extractor.cpython-312.pyc differ diff --git a/agents/__pycache__/hallucination_detector.cpython-311.pyc b/agents/__pycache__/hallucination_detector.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29e5dd4a1dcc6569c33ca533b73cbeb89fa4a6a0 Binary files /dev/null and b/agents/__pycache__/hallucination_detector.cpython-311.pyc differ diff --git a/agents/__pycache__/hallucination_detector.cpython-312.pyc b/agents/__pycache__/hallucination_detector.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40c24d5a186dadc5d0036a90047cd5c24b7461d6 Binary files /dev/null and b/agents/__pycache__/hallucination_detector.cpython-312.pyc differ diff --git a/agents/__pycache__/job_parser.cpython-311.pyc b/agents/__pycache__/job_parser.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48b646d288ae5a0c294ffb13b580ec7f82780fde Binary files /dev/null and b/agents/__pycache__/job_parser.cpython-311.pyc differ diff --git a/agents/__pycache__/job_parser.cpython-312.pyc b/agents/__pycache__/job_parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d53d44a5c646ee2ac91b60afcc78452d414aaa3 Binary files /dev/null and b/agents/__pycache__/job_parser.cpython-312.pyc differ diff --git a/agents/__pycache__/modal_model.cpython-311.pyc b/agents/__pycache__/modal_model.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ebdb73b9ffb0c35f22fa42cd1d2a3d9a02d45c3 Binary files /dev/null and b/agents/__pycache__/modal_model.cpython-311.pyc differ diff --git a/agents/__pycache__/modal_model.cpython-312.pyc b/agents/__pycache__/modal_model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..141f7f67fc94f729ad074a8d24c9462cd1095e03 Binary files /dev/null and b/agents/__pycache__/modal_model.cpython-312.pyc differ diff --git a/agents/__pycache__/name_extractor.cpython-311.pyc b/agents/__pycache__/name_extractor.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a535676225ef3d3acf695c2f82c0134d74a0f43 Binary files /dev/null and b/agents/__pycache__/name_extractor.cpython-311.pyc differ diff --git a/agents/__pycache__/name_extractor.cpython-312.pyc b/agents/__pycache__/name_extractor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96ca8875a142a36672b144ec253251a3881b8123 Binary files /dev/null and b/agents/__pycache__/name_extractor.cpython-312.pyc differ diff --git a/agents/__pycache__/optimizer.cpython-311.pyc b/agents/__pycache__/optimizer.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c5edf89c2c9af203d5c6823b2a892397c599740 Binary files /dev/null and b/agents/__pycache__/optimizer.cpython-311.pyc differ diff --git a/agents/__pycache__/optimizer.cpython-312.pyc b/agents/__pycache__/optimizer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01084eb664ca107d724f3e2e15e2ba648b9e9212 Binary files /dev/null and b/agents/__pycache__/optimizer.cpython-312.pyc differ diff --git a/agents/ai_generated_detector.py b/agents/ai_generated_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..7fa268419448ca429698698ef0f059f8b8575aa2 --- /dev/null +++ b/agents/ai_generated_detector.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field +from pydantic_ai import Agent +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.filters import FilterResult +from models.resume import HTMLResume + + +class AIGeneratedResult(BaseModel): + is_ai_generated: bool = Field(description="True if resume appears AI-generated") + ai_probability: float = Field(ge=0.0, le=1.0) + indicators: list[str] = Field(default_factory=list) + + +SYSTEM_PROMPT = """You detect AI-generated content in resumes. + +CRITICAL: Resumes are INTENTIONALLY formulaic. Every resume guide teaches: +- Action Verb + Task + Result pattern +- Consistent bullet structure and length +- Quantified metrics +- Industry keywords +This is GOOD resume writing, NOT AI tells. + +FLAG ONLY: +- Fabricated/impossible claims +- Internal contradictions +- Buzzword soup with zero specifics +- Generic filler repeated verbatim +- Hallucinated details + +Set is_ai_generated=true ONLY if ai_probability > 0.5. +When listing indicators, quote specific problematic text. +""" + + +def detect_ai_generated(resume: HTMLResume | str, settings: AppSettings) -> FilterResult: + content = resume.html if isinstance(resume, HTMLResume) else resume + agent = Agent( + build_modal_model(settings), + output_type=PromptedOutput(AIGeneratedResult, template="Return JSON matching this schema: {schema}"), + instructions=SYSTEM_PROMPT, + ) + result = agent.run_sync( + "Analyze this resume text for signs of AI generation while ignoring normal resume conventions.\n\n" + f"=== RESUME TEXT ===\n{content}\n=== END ===" + ) + output = result.output + feedback = "" + if output.indicators: + feedback = "AI-generation indicators:\n" + "\n".join(f"- {item}" for item in output.indicators) + return FilterResult( + filter_name="ai_generated", + passed=not output.is_ai_generated, + score=1.0 - output.ai_probability, + feedback=feedback, + detail=output.model_dump(mode="json"), + ) + diff --git a/agents/combined_reviewer.py b/agents/combined_reviewer.py new file mode 100644 index 0000000000000000000000000000000000000000..76cd210dc116a72dc2e6431437d0c0cdd08a92de --- /dev/null +++ b/agents/combined_reviewer.py @@ -0,0 +1,111 @@ +import re + +import fitz +from pydantic import BaseModel, Field +from pydantic_ai import Agent +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.job import JobPosting +from models.resume import HTMLResume + + +class CombinedReviewResult(BaseModel): + looks_professional: bool = Field(description="True if resume looks professional") + visual_issues: list[str] = Field(default_factory=list) + visual_feedback: str = "" + keyword_score: float = Field(ge=0.0, le=1.0) + experience_score: float = Field(ge=0.0, le=1.0) + education_score: float = Field(ge=0.0, le=1.0) + overall_fit_score: float = Field(ge=0.0, le=1.0) + disqualified: bool + ats_issues: list[str] = Field(default_factory=list) + + +SCORE_WEIGHTS = { + "keyword": 0.25, + "experience": 0.175, + "education": 0.075, + "overall_fit": 0.50, +} + + +SYSTEM_PROMPT = """ +You are TalentScreen ATS v4.2, an enterprise applicant tracking system. + +Evaluate: +1. Resume text/HTML quality and professionalism +2. ATS fit against the job posting + +VISUAL/TEXT QUALITY: +- Clean organization, readable sections, consistent bullet structure +- No broken/mangled text, duplicate sections, or obvious formatting artifacts +- Professional tone, active voice, no slang + +ATS SCREENING: +- keyword_score: exact and semantic matches to job requirements/keywords +- experience_score: work history demonstrates required competencies +- education_score: education fit if the role requires it +- overall_fit_score: holistic fit for the role +- disqualified=true only for strong auto-reject reasons + +Return all fields. +""" + + +def pdf_to_image(pdf_bytes: bytes) -> tuple[bytes, int]: + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + try: + page_count = len(doc) + page = doc[0] + pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) + return pix.tobytes("png"), page_count + finally: + doc.close() + + +def combined_review( + optimized: HTMLResume | str, + job: JobPosting, + settings: AppSettings, + pdf_text: str | None = None, +) -> CombinedReviewResult: + content = optimized.html if isinstance(optimized, HTMLResume) else optimized + resume_text = pdf_text or _html_to_text(content) + agent = Agent( + build_modal_model(settings), + output_type=PromptedOutput(CombinedReviewResult, template="Return JSON matching this schema: {schema}"), + instructions=SYSTEM_PROMPT, + ) + result = agent.run_sync( + "COMBINED RESUME REVIEW\n\n" + "=== JOB POSTING ===\n" + f"Position: {job.title}\n" + f"Company: {job.company}\n" + f"Description: {job.description}\n" + f"Required Skills: {', '.join(job.requirements)}\n" + f"Keywords: {', '.join(job.keywords)}\n\n" + "=== RESUME TEXT ===\n" + f"{resume_text}\n\n" + "=== RESUME HTML ===\n" + f"{content[:12000]}" + ) + return result.output + + +def compute_ats_score(result: CombinedReviewResult) -> float: + return ( + result.keyword_score * SCORE_WEIGHTS["keyword"] + + result.experience_score * SCORE_WEIGHTS["experience"] + + result.education_score * SCORE_WEIGHTS["education"] + + result.overall_fit_score * SCORE_WEIGHTS["overall_fit"] + ) + + +def _html_to_text(html: str) -> str: + text = re.sub(r"", " ", html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"", " ", html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"<[^>]+>", " ", text) + return re.sub(r"\s+", " ", text).strip() + diff --git a/agents/extractor.py b/agents/extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..673263eb7727fc9992e83741712442e2e2b593b3 --- /dev/null +++ b/agents/extractor.py @@ -0,0 +1,269 @@ +from collections.abc import Iterable +import re +from typing import TypeVar + +from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.cv import CVData, Contact, Education, Experience, Project, SkillsData, WorkExperience + + +_SUMMARY_PROMPT = """Extract career summaries from the document. +Include: professional headlines, "About" / LinkedIn summaries, career objective statements. +Each distinct paragraph = one list entry. Return [] if none found. +Do not invent anything not explicitly stated.""" + +_EXPERIENCE_PROMPT = """Extract work experience entries from the document. +Each distinct role = one entry: employer, title, start date, end date, bullet points. +Preserve exact dates, company names, and metrics as written. +Return [] if none found. Do not invent anything.""" + +_EDUCATION_PROMPT = """Extract education entries from the document. +Each degree or program = one entry: institution, degree, field, start/end dates, notes (GPA, honors, coursework). +Return [] if none found. Do not invent anything.""" + +_SKILLS_PROMPT = """Extract skills from the document into four categories: +- technical: programming languages, frameworks, tools, cloud platforms, databases +- languages: spoken/written human languages (English, Russian, etc.) +- certifications: ONLY named credentials, licenses, certificates, or exams explicitly listed as certifications (e.g. "AWS Certified Developer 2022", "PMP", "CPA"). Do NOT put job duties, experience bullets, projects, employers, roles, or education here. +- awards: prizes, honors, recognition +Return empty lists for absent categories. Do not invent anything.""" + +_PROJECTS_PROMPT = """Extract side projects, open-source work, and research projects. +Each project: name, short description, URL only if explicitly written in the document, key bullet points. +Return [] if none found. +STRICT: Do NOT construct or infer URLs — only copy URLs that appear verbatim in the document text.""" + +_PUBLICATIONS_PROMPT = """Extract publications, papers, patents, and articles. +One free-form string per item: authors, title, venue, year — and include the DOI at the end in parentheses if present in the document, e.g. "(DOI: 10.xxxx/xxxx)". +Return [] if none found. Do not invent anything. +STRICT: Do NOT include work experience bullet points, project bullet points, job achievements, repositories, dashboards, or internal tools unless they are explicitly listed as publications, papers, patents, or articles.""" + +_CONTACT_PROMPT = """Extract personal contact information from the document. +- name: full name of the candidate as written (first + last) +- email: email address +- phone: phone number (any format) +- linkedin: LinkedIn profile URL (full URL or linkedin.com/in/...) +- github: GitHub profile URL or username (full URL or github.com/...) +- website: personal website or portfolio URL +- other_links: ONLY URLs that are explicitly written in the document verbatim — do NOT construct or infer URLs from names or usernames + +STRICT RULES: +- Return null for any field not explicitly present in the document +- NEVER construct a URL by combining a username with a domain (e.g. do NOT write github.com/user/project unless that exact URL appears in the text) +- other_links must only contain URLs copied verbatim from the document""" + +_BASE_INSTRUCTIONS = ( + "You extract structured facts from CV text. Return only data supported by the document. " + "Do not infer, normalize, summarize beyond the requested shape, or add commentary." +) + +T = TypeVar("T") + + +class SummaryOutput(BaseModel): + items: list[str] = [] + + +class ExperienceOutput(BaseModel): + items: list[WorkExperience] = [] + + +class EducationOutput(BaseModel): + items: list[Education] = [] + + +class ProjectsOutput(BaseModel): + items: list[Project] = [] + + +class PublicationsOutput(BaseModel): + items: list[str] = [] + + +def extract(cv_text: str, settings: AppSettings) -> CVData: + model = _build_model(settings) + + contact = _run_agent(model, Contact, _CONTACT_PROMPT, cv_text) + summaries = _run_agent(model, SummaryOutput, _SUMMARY_PROMPT, cv_text).items + work_entries = _run_agent(model, ExperienceOutput, _EXPERIENCE_PROMPT, cv_text).items + education = _run_agent(model, EducationOutput, _EDUCATION_PROMPT, cv_text).items + skills = _run_agent(model, SkillsData, _SKILLS_PROMPT, cv_text) + projects = _run_agent(model, ProjectsOutput, _PROJECTS_PROMPT, cv_text).items + publications = _run_agent(model, PublicationsOutput, _PUBLICATIONS_PROMPT, cv_text).items + + _apply_contact_fallbacks(contact, cv_text) + name = contact.name or _fallback_name(cv_text) + return CVData( + name=name, + contact=contact, + summary="\n\n".join(summaries) if summaries else None, + experience=[_to_legacy_experience(item) for item in work_entries], + education=education, + skills=_dedupe(skills.technical), + certifications=_clean_certifications(skills.certifications, cv_text), + awards=_dedupe(skills.awards), + languages=_dedupe(skills.languages), + projects=projects, + publications=_clean_publications(publications, cv_text), + raw_text=cv_text, + ) + + +def _build_model(settings: AppSettings) -> OpenAIChatModel: + return build_modal_model(settings) + + +def _run_agent(model: OpenAIChatModel, output_type: type[T], prompt: str, cv_text: str) -> T: + agent = Agent( + model, + output_type=PromptedOutput(output_type, template="Return JSON matching this schema: {schema}"), + instructions=f"{_BASE_INSTRUCTIONS}\n\n{prompt}", + ) + result = agent.run_sync(f"Document text:\n\n{cv_text}") + return result.output + + +def _to_legacy_experience(item: WorkExperience) -> Experience: + return Experience( + company=item.employer or "", + title=item.title or "", + start=item.start_date or "", + end=item.end_date, + bullets=item.bullet_points, + ) + + +def _fallback_name(cv_text: str) -> str: + for line in cv_text.splitlines(): + candidate = line.strip() + if candidate: + return candidate[:120] + return "Unknown Candidate" + + +def _apply_contact_fallbacks(contact: Contact, cv_text: str) -> None: + if not contact.email: + match = re.search(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+", cv_text) + if match: + contact.email = match.group(0) + if not contact.linkedin: + match = re.search(r"(?:https?://)?(?:www\.)?linkedin\.com/in/[^\s|,;]+", cv_text, re.IGNORECASE) + if match: + contact.linkedin = match.group(0) + if not contact.github: + match = re.search(r"(?:https?://)?(?:www\.)?github\.com/[^\s|,;]+", cv_text, re.IGNORECASE) + if match: + contact.github = match.group(0) + if not contact.website: + urls = re.findall(r"https?://[^\s|,;]+", cv_text) + known = {value for value in (contact.linkedin, contact.github) if value} + for url in urls: + if url not in known: + contact.website = url + break + + +def _clean_publications(items: Iterable[str], cv_text: str) -> list[str]: + if not (_has_section_heading(cv_text, ("publications", "publication", "papers", "patents")) or _has_publication_identifier(cv_text)): + return [] + + publications: list[str] = [] + for item in _dedupe(items): + if _looks_like_experience_bullet(item): + continue + if _has_publication_identifier(item) or _looks_like_publication_citation(item): + publications.append(item) + return publications + + +def _clean_certifications(items: Iterable[str], cv_text: str) -> list[str]: + has_cert_section = _has_section_heading(cv_text, ("certifications", "certification", "certificates", "licenses")) + certifications: list[str] = [] + for item in _dedupe(items): + if _looks_like_experience_bullet(item): + continue + if _looks_like_certification(item) or has_cert_section and _looks_like_short_named_item(item): + certifications.append(item) + return certifications + + +def _has_section_heading(text: str, headings: tuple[str, ...]) -> bool: + for line in text.splitlines(): + normalized = re.sub(r"[^a-z]+", " ", line.lower()).strip() + if normalized in headings: + return True + return False + + +def _has_publication_identifier(text: str) -> bool: + lowered = text.lower() + return bool( + re.search(r"\bdoi\s*:\s*10\.\S+", lowered) + or re.search(r"\b10\.\d{4,9}/\S+", lowered) + or re.search(r"\barxiv\s*:?\s*\d", lowered) + or re.search(r"\bpatent(?:s|ed)?\b", lowered) + ) + + +def _looks_like_publication_citation(item: str) -> bool: + lowered = item.lower() + if any(word in lowered for word in ("journal", "conference", "proceedings", "transactions", "published", "publication")): + return True + return bool(re.search(r"\b(?:19|20)\d{2}\b", item) and re.search(r"[“\"].+[”\"]", item)) + + +def _looks_like_certification(item: str) -> bool: + lowered = item.lower() + certification_markers = ( + "certified", + "certification", + "certificate", + "license", + "licence", + "credential", + "pmp", + "cpa", + "cfa", + "ccna", + "cissp", + "aws certified", + "azure certified", + "google cloud certified", + ) + return any(marker in lowered for marker in certification_markers) and _looks_like_short_named_item(item) + + +def _looks_like_short_named_item(item: str) -> bool: + words = item.split() + return 1 <= len(words) <= 12 and len(item) <= 120 + + +def _looks_like_experience_bullet(item: str) -> bool: + normalized = item.strip() + lowered = normalized.lower() + if not normalized: + return False + if len(normalized.split()) > 14: + return True + if re.search(r"\b(?:built|developed|created|implemented|managed|led|improved|deployed|maintained|designed|worked|processed|optimized|reduced|increased|delivered|collaborated)\b", lowered): + return True + if re.search(r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{4})\b\s*[-–]\s*(?:present|\d{4}|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)", lowered): + return True + return False + + +def _dedupe(items: Iterable[str]) -> list[str]: + values: list[str] = [] + seen: set[str] = set() + for item in items: + normalized = " ".join(item.split()) + key = normalized.lower() + if normalized and key not in seen: + values.append(normalized) + seen.add(key) + return values diff --git a/agents/hallucination_detector.py b/agents/hallucination_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..4dfc369fdfeef2c07a3a340be044d69b725a4eee --- /dev/null +++ b/agents/hallucination_detector.py @@ -0,0 +1,101 @@ +from pydantic import BaseModel, Field +from pydantic_ai import Agent +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.filters import FilterResult +from models.resume import HTMLResume + + +class HallucinationResult(BaseModel): + no_hallucination_score: float = Field( + ge=0.0, + le=1.0, + description="Score from 0 to 1 where 1.0 = no fabrications, 0.0 = severe fabrications", + ) + concerns: list[str] = Field(default_factory=list) + reasoning: str = "" + + +STRICT_PROMPT = """You are a resume verification specialist. +Compare an ORIGINAL resume with an OPTIMIZED version and return a no_hallucination_score from 0.0 to 1.0. + +SCORING GUIDE: +- 1.0: Perfect - all content traceable to original, only rephrasing/restructuring +- 0.9-0.99: Minor acceptable additions (related tech inference, umbrella terms) +- 0.8-0.9: Light assumptions that are reasonable but noticeable +- 0.7-0.8: Questionable additions - somewhat plausible but stretching +- 0.5-0.69: Significant fabrications - claims that may not be true +- 0.0-0.49: Severe fabrications - fake jobs, degrees, major false claims + +SERIOUS FABRICATIONS (score below 0.5): +- Fabricated job titles, companies, or employment dates +- Invented degrees, certifications, or institutions +- Made-up metrics with specific numbers not in original +- Fake achievements, publications, or awards +- Completely unrelated technologies +""" + +LENIENT_PROMPT = """You are a resume verification specialist. +Compare an ORIGINAL resume with an OPTIMIZED version and return a no_hallucination_score from 0.0 to 1.0. + +SCORING GUIDE: +- 1.0: All content directly traceable to original +- 0.8-0.99: Aggressive skill extrapolations that are plausible from context +- 0.6-0.79: Significant embellishment of achievements, creative reframing +- 0.5-0.59: Very aggressive stretching but still plausible +- 0.0-0.49: Blatant fabrications - fake jobs, degrees, made-up credentials + +ACCEPTABLE (score 0.7+): +- Aggressive technology extrapolation: Python user -> any Python library, web dev -> full stack +- Adding plausible tools from job context even if not explicitly stated +- Creative reframing of responsibilities to match job requirements +- Inferring leadership/mentoring from senior roles +- Adding industry-standard practices plausible for their role + +BLOCK (score below 0.5): +- Fabricated job titles, companies, or employment dates +- Invented degrees, certifications, or institutions +- Made-up awards, publications, or patents +- Completely fictional projects or achievements +- Technologies with zero connection to stated experience +- Made up specific metrics +""" + + +def detect_hallucinations( + optimized: HTMLResume | str, + original_text: str, + settings: AppSettings, + job_text: str = "", + no_shame: bool = True, +) -> FilterResult: + optimized_content = optimized.html if isinstance(optimized, HTMLResume) else optimized + threshold = 0.5 if no_shame else 0.9 + prompt = LENIENT_PROMPT if no_shame else STRICT_PROMPT + agent = Agent( + build_modal_model(settings), + output_type=PromptedOutput(HallucinationResult, template="Return JSON matching this schema: {schema}"), + instructions=prompt, + ) + result = agent.run_sync( + "Compare these two resumes and score the optimized version for hallucinations.\n\n" + f"=== ORIGINAL RESUME ===\n{original_text}\n\n" + f"=== JOB POSTING CONTEXT ===\n{job_text}\n\n" + f"=== OPTIMIZED RESUME ===\n{optimized_content}" + ) + output = result.output + passed = output.no_hallucination_score >= threshold + feedback = "" + if not passed: + concerns = "\n".join(f"- {item}" for item in output.concerns) + feedback = f"Score {output.no_hallucination_score:.2f} below {threshold:.2f}. {output.reasoning}\n{concerns}".strip() + return FilterResult( + filter_name="hallucination", + passed=passed, + score=output.no_hallucination_score, + feedback=feedback, + detail=output.model_dump(mode="json") | {"threshold": threshold}, + ) + diff --git a/agents/job_parser.py b/agents/job_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..586113e699890937dd2eb523659910024e3b30b5 --- /dev/null +++ b/agents/job_parser.py @@ -0,0 +1,34 @@ +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.job import JobPosting + + +SYSTEM_PROMPT = """You are a job posting parser. Extract structured information from job postings. + +Extract: +- title: The job title +- company: Company name +- requirements: List of specific requirements (skills, experience, education) +- keywords: Technical keywords, tools, technologies mentioned +- description: Brief summary of the role + +Be thorough in extracting keywords - include all technologies, tools, frameworks, methodologies mentioned. +""" + + +def parse_job_posting(job_text: str, settings: AppSettings) -> JobPosting: + agent = Agent( + _build_model(settings), + output_type=PromptedOutput(JobPosting, template="Return JSON matching this schema: {schema}"), + instructions=SYSTEM_PROMPT, + ) + result = agent.run_sync(f"Job posting text:\n\n{job_text}") + return result.output + + +def _build_model(settings: AppSettings) -> OpenAIChatModel: + return build_modal_model(settings) diff --git a/agents/modal_model.py b/agents/modal_model.py new file mode 100644 index 0000000000000000000000000000000000000000..264e1419f0dee8e9548c555779d63c4c344a6a37 --- /dev/null +++ b/agents/modal_model.py @@ -0,0 +1,45 @@ +from openai import AsyncOpenAI +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from models.config import AppSettings + + +def build_modal_model(settings: AppSettings) -> OpenAIChatModel: + is_qwen = settings.model.name.lower().startswith("qwen") + client = AsyncOpenAI( + base_url=settings.model.base_url, + api_key=settings.model.api_key, + timeout=180, + max_retries=1, + ) + return OpenAIChatModel( + settings.model.name, + provider=OpenAIProvider(openai_client=client), + settings=_modal_model_settings(settings), + system_prompt_role="user" if is_qwen else None, + ) + + +def _modal_model_settings(settings: AppSettings) -> dict: + model_name = settings.model.name.lower() + model_settings: dict = { + "temperature": settings.model.temperature, + "max_tokens": settings.model.max_tokens, + } + if model_name.startswith("qwen"): + model_settings.update( + { + "top_p": 0.8, + "presence_penalty": 1.5, + "extra_body": { + "top_k": 20, + "chat_template_kwargs": {"enable_thinking": False}, + }, + } + ) + elif "nemotron" in model_name: + model_settings["extra_body"] = { + "chat_template_kwargs": {"enable_thinking": False}, + } + return model_settings diff --git a/agents/name_extractor.py b/agents/name_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..af2e94ae9b395e5a1b58ce067455a831e960f4a1 --- /dev/null +++ b/agents/name_extractor.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings + + +class ExtractedName(BaseModel): + first_name: str | None = None + last_name: str | None = None + language_code: str = "en" + + +SYSTEM_PROMPT = """Extract the person's name from this resume/CV content. + +Return: +- first_name: The person's first/given name +- last_name: The person's last/family name (may include middle names) + +If you cannot find a name, return null for both fields. +Handle any format: LaTeX, plain text, markdown, HTML, etc. +Ignore formatting commands - extract the actual name text only. +""" + + +def extract_name(content: str, settings: AppSettings) -> tuple[str | None, str | None, str]: + agent = Agent( + build_modal_model(settings), + output_type=PromptedOutput(ExtractedName, template="Return JSON matching this schema: {schema}"), + instructions=SYSTEM_PROMPT, + ) + result = agent.run_sync(f"Extract the name from this resume:\n\n{content[:3000]}") + output = result.output + return output.first_name, output.last_name, output.language_code + diff --git a/agents/optimizer.py b/agents/optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..b1023e4e6a88a2dce62c5acbb3d71908465200d1 --- /dev/null +++ b/agents/optimizer.py @@ -0,0 +1,226 @@ +import logging +import re +from datetime import date +from pathlib import Path + +from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.output import PromptedOutput + +from agents.modal_model import build_modal_model +from models.config import AppSettings +from models.cv import CVData +from models.resume import HTMLResume + +logger = logging.getLogger(__name__) + +TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates" + + +class OptimizerResult(BaseModel): + html: str + changes: list[str] = [] + + +def _load_resume_guide() -> str: + guide_path = TEMPLATE_DIR / "resume_guide.md" + if not guide_path.exists(): + return "" + return guide_path.read_text(encoding="utf-8") + + +OPTIMIZER_BASE = r""" +You are a resume optimization expert. Use the parsed resume data and create optimized HTML for a job posting. + +INPUT: Parsed candidate resume JSON and job posting text. + +OUTPUT: Generate HTML for the of a resume PDF. Do NOT include , , or tags - only the body content. + +CONTENT RULES: +- When describing job experiences, show concrete results: focus on impact, not tasks. +- Include specific technologies within achievement descriptions. +- Feature keywords matching job requirements IF they exist in the original resume. You can add umbrella terms if relevant (e.g. if user was making transformer LLM models you can add "NLP"). +- Prioritize and highlight experiences most relevant to the role. +- If going over one page: remove unrelated content to save space. +- Remove obvious skills (Excel, VS Code, Jupyter, GitHub, Jira) unless specifically required by job or very relevant to it. +- Exclude: location, language proficiency, age, hobbies unless required by job posting. +- Add a summary section highlighting the most relevant experiences. +- Try to preserve the original writing style if possible. +- Avoid leaving empty space at the bottom of the page if useful relevant content can fill it. +- PROJECTS: Only include projects directly relevant to this job. Skip projects already listed under Publications. If no projects are relevant, omit the section. +- PUBLICATIONS: Always use "PUBLICATIONS" as the section title when publications are present. +- EDUCATION: By default include only the most recent / highest degree. Include multiple degrees only if both are relevant. + +{content_rules} + +CONTENT BUDGET: +- Target: about 500 words and about 4000 characters. +- The pipeline will validate length, structure, keyword coverage, hallucination risk, and renderability after you return. +- If previous feedback is provided, make the smallest possible change to address that feedback. + +LINKS: +- Preserve contact info from the original and never delete it. +- Preserve URLs from the original resume: email, LinkedIn, GitHub, website, project links. +- Use full URLs (include https://) in the href attribute of every tag. +- Link display text must NOT start with https:// or http://. Show just the domain+path. + +PUBLICATIONS: +- Always append the DOI in parentheses at the end if available, e.g. "Author et al., Title, Venue Year (DOI: 10.xxxx/xxxx)". + +TEMPLATE AND CSS: +- Use the provided template guide and CSS classes exactly where possible. +- Prefer semantic tags from the guide: header.header, h1.name, div.contact-line, section.section, h2.section-title, div.entry, ul.bullets, div.skills-list, ul.simple-list. +- You MUST include a header with the candidate name and available contact links. +- Do not emit Markdown. +- Do not emit wrapper tags. +- The guide examples are FORMAT EXAMPLES ONLY. Never copy example facts from the guide, including fake GPA, Dean's List, dates, companies, emails, URLs, projects, certifications, or publication titles. +- For education notes, include GPA, honors, coursework, or awards ONLY if they appear in the parsed resume JSON or original resume text. + +{resume_guide} +""" + +OPTIMIZER_STRICT_RULES = """ +ALLOWED: +- You CAN add related technologies plausible from context (e.g. Python user likely knows pip, venv; React user likely knows npm, webpack). +- General/umbrella terms inferable from context: "NLP" if they did text processing, "SQL" if they used databases. +- Rephrasing metrics with same values: "1% - 10%" -> "1-10%", "$10k" -> "$10,000". +- Reordering and emphasizing existing content. + +STRICT RULES - NEVER VIOLATE: +- NEVER add specific named products or platforms absent from the original unless they are a direct, obvious companion to something explicitly present and there is no other way to improve fit. +- NEVER fabricate job titles, companies, degrees, certifications, achievements, publications, patents, awards, or projects. +- NEVER copy example facts from the template guide into the candidate resume. +- NEVER invent metrics, numbers, and achievements not in original. +- Do NOT drop critical work experience or achievements unless they decrease fit. +- Never use the em dash symbol, the word "delve", or other common markers of LLM-generated text. +- NEVER add ", " ", html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"<[^>]+>", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _heuristic_result(text: str, cv_data: CVData, jd_text: str) -> FilterResult: + claims = _extract_claims(_strip_style_noise(text)) + flagged = [] + evidence = _build_evidence_text(cv_data) + for claim in claims: + claim = _collapse_repeated_claim(claim) + if _is_ignorable_claim(claim, jd_text): + continue + score = fuzz.partial_ratio(claim.lower(), evidence.lower()) + if score < 85: + flagged.append({"claim": claim, "score": score}) + feedback = "" + if flagged: + feedback = "Potential unsupported claims found:\n" + "\n".join( + f"- {item['claim']} ({item['score']:.0f})" for item in flagged[:20] + ) + return FilterResult( + filter_name="hallucination", + passed=not flagged, + score=1.0 if not flagged else 0.0, + feedback=feedback, + detail={"flagged": flagged[:50], "mode": "heuristic"}, + ) + + +def _extract_claims(text: str) -> list[str]: + patterns = [ + r"\b(?:[A-Z][a-zA-Z&.-]+(?:\s+[A-Z][a-zA-Z&.-]+){1,3})\b", + r"\b\d+(?:[.,]\d+)?%?\b", + r"\b(?:19|20)\d{2}\b", + r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(?:19|20)?\d{2,4}\b", + ] + claims: list[str] = [] + seen: set[str] = set() + for pattern in patterns: + for match in re.findall(pattern, text): + claim = match.strip() + if _is_noisy_claim_candidate(claim): + continue + if len(claim) < 3 or claim.lower() in seen: + continue + seen.add(claim.lower()) + claims.append(claim) + return claims + + +def _strip_style_noise(text: str) -> str: + style_words = ("Arial", "Calibri", "Georgia", "Helvetica", "Times New Roman", "font", "margin", "padding", "color", "border") + cleaned = text + for word in style_words: + cleaned = re.sub(rf"\b{re.escape(word)}\b", " ", cleaned, flags=re.IGNORECASE) + return re.sub(r"\s+", " ", cleaned) + + +def _is_noisy_claim_candidate(claim: str) -> bool: + lowered_words = [word.lower().strip(".") for word in claim.split()] + section_words = { + "experience", + "education", + "skills", + "projects", + "summary", + "profile", + "contact", + "work", + "professional", + "technical", + "languages", + "certifications", + "certification", + "awards", + "publications", + } + if any(word in section_words for word in lowered_words): + return True + if all(len(word) <= 3 for word in lowered_words): + return True + return False + + +def _build_evidence_text(cv_data: CVData) -> str: + parts = [cv_data.raw_text, cv_data.name] + contact = cv_data.contact + parts.extend(value for value in (contact.name, contact.email, contact.phone, contact.linkedin, contact.github, contact.website, contact.location) if value) + parts.extend(contact.other_links) + if cv_data.summary: + parts.append(cv_data.summary) + for experience in cv_data.experience: + parts.extend([experience.company, experience.title, experience.start, experience.end or ""]) + parts.extend(experience.bullets) + for education in cv_data.education: + parts.extend([education.institution, education.degree, education.field or "", education.start_date or "", education.end_date or "", education.year or ""]) + parts.extend(education.notes) + parts.extend(cv_data.skills) + parts.extend(cv_data.certifications) + parts.extend(cv_data.awards) + parts.extend(cv_data.languages) + for project in cv_data.projects: + parts.extend([project.name or "", project.short_description or "", project.url or ""]) + parts.extend(project.key_bullet_points) + parts.extend(cv_data.publications) + return "\n".join(part for part in parts if part) + + +def _is_ignorable_claim(claim: str, jd_text: str) -> bool: + normalized = claim.strip() + lowered = normalized.lower() + generic_claims = { + "experience", + "education", + "skills", + "projects", + "summary", + "profile", + "contact", + "work experience", + "professional experience", + "technical skills", + "languages", + "certifications", + "present", + "resume", + "cv", + "senior", + "engineer", + "software engineer", + "arial", + "calibri", + "georgia", + "helvetica", + } + if lowered in generic_claims: + return True + if len(normalized) <= 3: + return True + if lowered in jd_text.lower(): + return True + if normalized.isdigit() and len(normalized) < 4: + return True + return False + + +def _collapse_repeated_claim(claim: str) -> str: + words = claim.split() + if len(words) % 2 != 0: + return claim + midpoint = len(words) // 2 + if words[:midpoint] == words[midpoint:]: + return " ".join(words[:midpoint]) + return claim diff --git a/filters/keyword.py b/filters/keyword.py new file mode 100644 index 0000000000000000000000000000000000000000..d583b1c4b339b80f0f63ff22172aaffa5ad4f5d3 --- /dev/null +++ b/filters/keyword.py @@ -0,0 +1,63 @@ +import math +import re +from collections import Counter + +import spacy + +from filters.base import Filter +from models.config import AppSettings +from models.cv import CVData +from models.filters import FilterResult + +_NLP = None + + +class KeywordFilter(Filter): + name = "keyword" + priority = 3 + + def run(self, html: str, cv_data: CVData, jd_text: str, settings: AppSettings | None = None) -> FilterResult: + keywords = _extract_keywords(jd_text) + if not keywords: + return FilterResult(filter_name=self.name, passed=True, score=1.0, detail={"keywords": []}) + resume_text = re.sub(r"<[^>]+>", " ", html).lower() + present = [kw for kw in keywords if kw.lower() in resume_text] + score = len(present) / len(keywords) + missing = [kw for kw in keywords if kw not in present][:15] + passed = score >= 0.65 + feedback = "" if passed else "Missing important job-description keywords: " + ", ".join(missing) + return FilterResult( + filter_name=self.name, + passed=passed, + score=score, + feedback=feedback, + detail={"keywords": keywords, "present": present, "missing": missing}, + ) + + +def _extract_keywords(text: str) -> list[str]: + nlp = _load_nlp() + if nlp: + doc = nlp(text) + terms = [chunk.text.strip().lower() for chunk in doc.noun_chunks if 2 <= len(chunk.text.strip()) <= 40] + terms.extend(token.lemma_.lower() for token in doc if token.pos_ in {"PROPN", "NOUN"} and not token.is_stop) + else: + terms = re.findall(r"\b[a-zA-Z][a-zA-Z0-9+#.-]{2,}\b", text.lower()) + + counts = Counter(term for term in terms if len(term) > 2) + if not counts: + return [] + total = sum(counts.values()) + scored = [(term, count * math.log(1 + total / count)) for term, count in counts.items()] + return [term for term, _ in sorted(scored, key=lambda item: item[1], reverse=True)[:25]] + + +def _load_nlp(): + global _NLP + if _NLP is not None: + return _NLP + try: + _NLP = spacy.load("en_core_web_sm") + except Exception: + _NLP = False + return _NLP diff --git a/filters/runner.py b/filters/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..5e0f4ebf05817caefbdfc7c54c2303f813b78d7a --- /dev/null +++ b/filters/runner.py @@ -0,0 +1,28 @@ +from filters.base import Filter +from models.config import AppSettings +from models.cv import CVData +from models.filters import FilterReport +from models.resume import HTMLResume + + +def run_all( + html_resume: HTMLResume, + cv_data: CVData, + jd_text: str, + filters: list[Filter], + settings: AppSettings | None = None, +) -> FilterReport: + results = [] + for item in sorted(filters, key=lambda x: x.priority): + result = item.run(html_resume.html, cv_data, jd_text, settings) + results.append(result) + if not result.passed and item.hard_fail: + return FilterReport( + results=results, + all_passed=False, + combined_feedback=result.feedback, + hard_failed=True, + ) + all_passed = all(result.passed for result in results) + combined = "\n".join(result.feedback for result in results if not result.passed) + return FilterReport(results=results, all_passed=all_passed, combined_feedback=combined) diff --git a/filters/structure.py b/filters/structure.py new file mode 100644 index 0000000000000000000000000000000000000000..3356201014e62975fea20e91b44eafe0fcd5c775 --- /dev/null +++ b/filters/structure.py @@ -0,0 +1,85 @@ +import re +import tempfile +from html.parser import HTMLParser + +from core.renderer import _ensure_homebrew_library_path, _wrap_resume_html +from filters.base import Filter +from models.config import AppSettings +from models.cv import CVData +from models.filters import FilterResult + + +class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + @property + def text(self) -> str: + return " ".join(self.parts) + + +class StructureFilter(Filter): + name = "structure" + priority = 1 + min_words = 300 + ideal_min_words = 400 + max_words = 750 + + def run(self, html: str, cv_data: CVData, jd_text: str, settings: AppSettings | None = None) -> FilterResult: + parser = _TextExtractor() + parser.feed(html) + text = re.sub(r"\s+", " ", parser.text).strip() + lowered = text.lower() + failures: list[str] = [] + + if not any(keyword in lowered for keyword in ("experience", "work")): + failures.append("Missing Experience/Work section.") + if "education" not in lowered: + failures.append("Missing Education section.") + if "skills" not in lowered: + failures.append("Missing Skills section.") + if any(marker.lower() in lowered for marker in ("[your name]", "lorem ipsum", "insert")): + failures.append("Contains placeholder text.") + + word_count = len(re.findall(r"\b\w+\b", text)) + warnings: list[str] = [] + if word_count < self.min_words: + failures.append(f"Word count is too low; expected at least {self.min_words}, got {word_count}.") + elif word_count < self.ideal_min_words: + warnings.append(f"Resume is concise ({word_count} words); ideal range starts around {self.ideal_min_words}.") + elif word_count > self.max_words: + failures.append(f"Word count is too high; expected at most {self.max_words}, got {word_count}.") + + page_count = None + try: + _ensure_homebrew_library_path() + from pypdf import PdfReader + from weasyprint import HTML + + with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp: + HTML(string=_wrap_resume_html(html)).write_pdf(tmp.name) + page_count = len(PdfReader(tmp.name).pages) + if page_count != 1: + failures.append(f"Rendered PDF must be exactly 1 page; got {page_count}.") + except Exception as exc: + failures.append(f"Could not render HTML to PDF: {exc}") + + return FilterResult( + filter_name=self.name, + passed=not failures, + score=_word_count_score(word_count, self.min_words, self.ideal_min_words, self.max_words) if not failures else 0.0, + feedback="\n".join(failures), + detail={"word_count": word_count, "page_count": page_count, "warnings": warnings}, + ) + + +def _word_count_score(word_count: int, min_words: int, ideal_min_words: int, max_words: int) -> float: + if ideal_min_words <= word_count <= max_words: + return 1.0 + if min_words <= word_count < ideal_min_words: + return max(0.75, word_count / ideal_min_words) + return 0.0 diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23bc5dc94b36ecfc18e20e67cee6d0b5585c4674 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +"""Pydantic data models for EZ JOB.""" + diff --git a/models/__pycache__/__init__.cpython-311.pyc b/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5683dbf47f955b851365132b37e12e7ca5a51d1a Binary files /dev/null and b/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90268d1e0632b0fc3383cc8e0bc7e34d880168fc Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/config.cpython-311.pyc b/models/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1641b49356ecc5d8bf73e54bebdddbe81069c21a Binary files /dev/null and b/models/__pycache__/config.cpython-311.pyc differ diff --git a/models/__pycache__/config.cpython-312.pyc b/models/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d61ebc1c4e77c5c5d8e1078ffe72899a9a62c151 Binary files /dev/null and b/models/__pycache__/config.cpython-312.pyc differ diff --git a/models/__pycache__/cv.cpython-311.pyc b/models/__pycache__/cv.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a63fb63d4b3b947b8839bd9f2da87a51b26acee0 Binary files /dev/null and b/models/__pycache__/cv.cpython-311.pyc differ diff --git a/models/__pycache__/cv.cpython-312.pyc b/models/__pycache__/cv.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35bc39de00590b6ebcc7a0c891f50aca080f9240 Binary files /dev/null and b/models/__pycache__/cv.cpython-312.pyc differ diff --git a/models/__pycache__/filters.cpython-311.pyc b/models/__pycache__/filters.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c464e70cd51e7c28ef64db7f3a48a491c8f115f3 Binary files /dev/null and b/models/__pycache__/filters.cpython-311.pyc differ diff --git a/models/__pycache__/filters.cpython-312.pyc b/models/__pycache__/filters.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ebf66e09aeefd29b14e5003541e55cacb57e255 Binary files /dev/null and b/models/__pycache__/filters.cpython-312.pyc differ diff --git a/models/__pycache__/job.cpython-311.pyc b/models/__pycache__/job.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7f8a10e6b21a0089e0f56b50506fba3713fcddc Binary files /dev/null and b/models/__pycache__/job.cpython-311.pyc differ diff --git a/models/__pycache__/job.cpython-312.pyc b/models/__pycache__/job.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a59167e56859cf1ef372af1dc77c389b522d68d2 Binary files /dev/null and b/models/__pycache__/job.cpython-312.pyc differ diff --git a/models/__pycache__/output.cpython-311.pyc b/models/__pycache__/output.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d293bc752fb8de832eba4fd4e525281885d724fc Binary files /dev/null and b/models/__pycache__/output.cpython-311.pyc differ diff --git a/models/__pycache__/output.cpython-312.pyc b/models/__pycache__/output.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe3bffe7ac33fb9299d6680926c9d23112274822 Binary files /dev/null and b/models/__pycache__/output.cpython-312.pyc differ diff --git a/models/__pycache__/pipeline.cpython-311.pyc b/models/__pycache__/pipeline.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff8d05d173ef0dc992dc2a2d92b12a28a8e2159a Binary files /dev/null and b/models/__pycache__/pipeline.cpython-311.pyc differ diff --git a/models/__pycache__/pipeline.cpython-312.pyc b/models/__pycache__/pipeline.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..722944a1fd148148c285be0a3708ae8ce5726a02 Binary files /dev/null and b/models/__pycache__/pipeline.cpython-312.pyc differ diff --git a/models/__pycache__/resume.cpython-311.pyc b/models/__pycache__/resume.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..56e8941ceab7b7226994d1ac4c232debec6b034e Binary files /dev/null and b/models/__pycache__/resume.cpython-311.pyc differ diff --git a/models/__pycache__/resume.cpython-312.pyc b/models/__pycache__/resume.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c9a9c259cba36e54ce979dca4de06bfdbcc3990 Binary files /dev/null and b/models/__pycache__/resume.cpython-312.pyc differ diff --git a/models/config.py b/models/config.py new file mode 100644 index 0000000000000000000000000000000000000000..88f5cb572f22e8a51711ec58e5a406b0990dc18a --- /dev/null +++ b/models/config.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ModelSettings(BaseModel): + name: str = "qwen3.5-27b-fp8" + base_url: str = "https://dan-sultanov005--ezjob-vllm-qwen.modal.run/v1" + api_key: str = "EMPTY" + temperature: float = 0.3 + max_tokens: int = 4096 + + +class ModelOption(BaseModel): + name: str + base_url: str + label: str + api_key: str = "EMPTY" + + +class AppSettings(BaseSettings): + model: ModelSettings = ModelSettings() + model_options: dict[str, ModelOption] = Field( + default_factory=lambda: { + "qwen": ModelOption( + label="Qwen 3.5 27B FP8", + name="qwen3.5-27b-fp8", + base_url="https://dan-sultanov005--ezjob-vllm-qwen.modal.run/v1", + ), + "nemotron": ModelOption( + label="Nemotron 3 Nano 30B BF16", + name="nemotron-3-nano-30b-bf16", + base_url="https://dan-sultanov005--ezjob-vllm-nemotron.modal.run/v1", + ), + } + ) + max_iterations: int = 4 + language: str = "en" + tone: Literal["professional", "concise", "executive"] = "professional" + output_dir: Path = Path("output") + debug: bool = False + + model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") diff --git a/models/cv.py b/models/cv.py new file mode 100644 index 0000000000000000000000000000000000000000..95c1c29a8fc969bfebf9aa069721e4a91a3af344 --- /dev/null +++ b/models/cv.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel + + +class Contact(BaseModel): + name: str | None = None + email: str | None = None + phone: str | None = None + linkedin: str | None = None + github: str | None = None + website: str | None = None + location: str | None = None + other_links: list[str] = [] + + +class Experience(BaseModel): + company: str = "" + title: str = "" + start: str = "" + end: str | None = None + bullets: list[str] + + +class WorkExperience(BaseModel): + employer: str | None = None + title: str | None = None + start_date: str | None = None + end_date: str | None = None + bullet_points: list[str] = [] + + +class Education(BaseModel): + institution: str = "" + degree: str = "" + field: str | None = None + start_date: str | None = None + end_date: str | None = None + year: str | None = None + notes: list[str] = [] + + +class SkillsData(BaseModel): + technical: list[str] = [] + languages: list[str] = [] + certifications: list[str] = [] + awards: list[str] = [] + + +class Project(BaseModel): + name: str | None = None + short_description: str | None = None + url: str | None = None + key_bullet_points: list[str] = [] + + +class CVData(BaseModel): + name: str + contact: Contact + summary: str | None = None + experience: list[Experience] + education: list[Education] + skills: list[str] + certifications: list[str] = [] + awards: list[str] = [] + languages: list[str] = [] + projects: list[Project] = [] + publications: list[str] = [] + raw_text: str diff --git a/models/filters.py b/models/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..b8139d80e7e2c974f75db7f1e3df933935940d5a --- /dev/null +++ b/models/filters.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + + +class FilterResult(BaseModel): + filter_name: str + passed: bool + score: float | None = None + feedback: str = "" + detail: dict = Field(default_factory=dict) + + +class FilterReport(BaseModel): + results: list[FilterResult] + all_passed: bool + combined_feedback: str + hard_failed: bool = False + diff --git a/models/job.py b/models/job.py new file mode 100644 index 0000000000000000000000000000000000000000..907b8e1f9dcb8a0648bca344a6e81f1224381e5b --- /dev/null +++ b/models/job.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class JobPosting(BaseModel): + title: str | None = None + company: str | None = None + requirements: list[str] = [] + keywords: list[str] = [] + description: str | None = None + diff --git a/models/output.py b/models/output.py new file mode 100644 index 0000000000000000000000000000000000000000..a27535357579142b4992979c83062d1e50f2f5bc --- /dev/null +++ b/models/output.py @@ -0,0 +1,27 @@ +from datetime import datetime +from pathlib import Path +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class IndexRecord(BaseModel): + run_id: str = Field(default_factory=lambda: str(uuid4())) + timestamp: datetime = Field(default_factory=datetime.utcnow) + input_filename: str + jd_snippet: str + output_pdf: str | None = None + iterations_used: int + model: str + all_filters_passed: bool + duration_seconds: float + + +class OutputIndex(BaseModel): + records: list[IndexRecord] = Field(default_factory=list) + + def append_and_save(self, record: IndexRecord, path: Path) -> None: + self.records.append(record) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.model_dump_json(indent=2), encoding="utf-8") + diff --git a/models/pipeline.py b/models/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..d41261a4b7f951ee2052a6cc24932ba16e7954e4 --- /dev/null +++ b/models/pipeline.py @@ -0,0 +1,24 @@ +from datetime import datetime +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +from models.filters import FilterReport + + +class StatusEvent(BaseModel): + step: Literal["extract", "optimize", "filter", "render", "done", "error"] + iteration: int | None = None + message: str + timestamp: datetime = Field(default_factory=datetime.utcnow) + + +class PipelineResult(BaseModel): + success: bool + output_pdf: Path | None = None + iterations_used: int = 0 + filter_report: FilterReport | None = None + error: str | None = None + debug_dir: Path | None = None + diff --git a/models/resume.py b/models/resume.py new file mode 100644 index 0000000000000000000000000000000000000000..c3340c37a998e73372d1aba232479963978e3465 --- /dev/null +++ b/models/resume.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class HTMLResume(BaseModel): + html: str + iteration: int = 0 + model_used: str = "" + changes: list[str] = [] + + +class OptimizerOutput(BaseModel): + resume: HTMLResume + target_company: str | None = None + target_role: str | None = None + language: str diff --git a/packages.txt b/packages.txt new file mode 100644 index 0000000000000000000000000000000000000000..413240afc3fd229bde8cd76b659a480f60d741f0 --- /dev/null +++ b/packages.txt @@ -0,0 +1,5 @@ +libpango-1.0-0 +libharfbuzz0b +libglib2.0-0 +libgdk-pixbuf2.0-0 +libffi-dev diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5d8b8037944eb91efcb28f072ae682949ebe0a13 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +gradio>=4.0 +fastapi +uvicorn +openai +pymupdf +weasyprint +rapidfuzz +spacy +pydantic>=2.0 +pydantic-settings +pydantic-ai-slim[openai] +jinja2 +python-multipart +pypdf +modal diff --git a/scripts/__pycache__/test_modal_llm.cpython-311.pyc b/scripts/__pycache__/test_modal_llm.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ca9739c5ae5c4f9114534e2f263ce171a996c27 Binary files /dev/null and b/scripts/__pycache__/test_modal_llm.cpython-311.pyc differ diff --git a/scripts/__pycache__/test_modal_llm.cpython-312.pyc b/scripts/__pycache__/test_modal_llm.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0b40ba6d2b117e6bbb792c378849010a25b3188 Binary files /dev/null and b/scripts/__pycache__/test_modal_llm.cpython-312.pyc differ diff --git a/scripts/test_modal_llm.py b/scripts/test_modal_llm.py new file mode 100644 index 0000000000000000000000000000000000000000..020bba0806aeba888d9eb4facb227269fe49a44e --- /dev/null +++ b/scripts/test_modal_llm.py @@ -0,0 +1,30 @@ +import argparse + +from openai import OpenAI + + +def main() -> None: + parser = argparse.ArgumentParser(description="Test an OpenAI-compatible Modal vLLM endpoint.") + parser.add_argument("--base-url", required=True, help="Endpoint base URL, for example https://...modal.run/v1") + parser.add_argument("--model", required=True, help="Served model name, for example minicpm-1b") + parser.add_argument("--prompt", default="Reply with one short sentence confirming the service works.") + args = parser.parse_args() + + client = OpenAI(base_url=args.base_url.rstrip("/") + "/", api_key="EMPTY") + extra_body = None + if args.model.lower().startswith("qwen"): + extra_body = {"top_k": 20, "chat_template_kwargs": {"enable_thinking": False}} + elif "nemotron" in args.model.lower(): + extra_body = {"chat_template_kwargs": {"enable_thinking": False}} + response = client.chat.completions.create( + model=args.model, + messages=[{"role": "user", "content": args.prompt}], + temperature=0.2, + max_tokens=128, + extra_body=extra_body, + ) + print(response.choices[0].message.content) + + +if __name__ == "__main__": + main() diff --git a/templates/resume.html b/templates/resume.html new file mode 100644 index 0000000000000000000000000000000000000000..814b2b2970b7c6e0f2e27782bce2a8ec8d0cb9cf --- /dev/null +++ b/templates/resume.html @@ -0,0 +1,343 @@ + + + + + {{ resume.contact.name }} - Resume + + + + +
+
{{ resume.contact.name }}
+
+ {% set contact_parts = [] %} + {% if resume.contact.email %} + {% set _ = contact_parts.append('' ~ resume.contact.email ~ '') %} + {% endif %} + {% if resume.contact.phone %} + {% set _ = contact_parts.append(resume.contact.phone) %} + {% endif %} + {% if resume.contact.location %} + {% set _ = contact_parts.append(resume.contact.location) %} + {% endif %} + {% if resume.contact.linkedin %} + {% set url = resume.contact.linkedin if resume.contact.linkedin.startswith('http') else 'https://' ~ resume.contact.linkedin %} + {% set _ = contact_parts.append('LinkedIn') %} + {% endif %} + {% if resume.contact.github %} + {% set url = resume.contact.github if resume.contact.github.startswith('http') else 'https://' ~ resume.contact.github %} + {% set _ = contact_parts.append('GitHub') %} + {% endif %} + {% if resume.contact.website %} + {% set url = resume.contact.website if resume.contact.website.startswith('http') else 'https://' ~ resume.contact.website %} + {% set _ = contact_parts.append('Website') %} + {% endif %} + {{ contact_parts | join('|') | safe }} +
+
+ + + {% if resume.summary %} +
+
Summary
+
+ {{ resume.summary }} +
+
+ {% endif %} + + + {% if resume.experience %} +
+
Experience
+
+ {% for exp in resume.experience %} +
+
+
+ {{ exp.company }}{% if exp.location %}, {{ exp.location }}{% endif %} +
{{ exp.title }} +
+ +
+ {% if exp.bullets %} +
    + {% for bullet in exp.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if resume.education %} +
+
Education
+
+ {% for edu in resume.education %} +
+
+
+ {{ edu.institution }}{% if edu.location %}, {{ edu.location }}{% endif %} +
{{ edu.degree }} +
+ +
+ {% if edu.details %} +
    + {% for detail in edu.details %} +
  • {{ detail }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if resume.skills %} +
+
Skills
+
+ {{ resume.skills | join(', ') }} +
+
+ {% endif %} + + + {% if resume.projects %} +
+
Projects
+
+ {% for proj in resume.projects %} +
+ + {% if proj.url %} + {% set url = proj.url if proj.url.startswith('http') else 'https://' ~ proj.url %} + {{ proj.name }} + {% else %} + {{ proj.name }} + {% endif %} + + {% if proj.description %} - {{ proj.description }}{% endif %} + {% if proj.bullets %} +
    + {% for bullet in proj.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if resume.certifications %} +
+
Certifications
+
+
    + {% for cert in resume.certifications %} +
  • {{ cert }}
  • + {% endfor %} +
+
+
+ {% endif %} + + + {% if resume.publications %} +
+
Publications
+
+
    + {% for pub in resume.publications %} +
  • {{ pub }}
  • + {% endfor %} +
+
+
+ {% endif %} + + diff --git a/templates/resume_guide.md b/templates/resume_guide.md new file mode 100644 index 0000000000000000000000000000000000000000..b651569afc01d85f0c6c2070917cb8027276647c --- /dev/null +++ b/templates/resume_guide.md @@ -0,0 +1,169 @@ +# Resume HTML Generation Guide + +You will generate HTML for the `` of a resume PDF. The wrapper HTML/CSS is already applied - you only output the body content. + +## CSS Classes Available + +These classes are pre-defined and styled. Use them exactly as shown: + +### Header +```html +
+

FULL NAME

+ +
+``` + +The contact line automatically wraps to a second line if there are many items — no extra CSS needed. + +### Sections +```html +
+

SECTION NAME

+
+ +
+
+``` + +### Summary +```html +
+ Summary text goes here. Keep it concise and impactful. +
+``` + +### Experience/Education Entry + +**IMPORTANT: Company and title go on ONE line, date on the right:** +```html +
+
+
+ Company Name - Job Title +
+ +
+
    +
  • Achievement or responsibility with quantified impact
  • +
  • Another bullet point
  • +
+
+``` + +For education: +```html +
+
+
+ University Name - BS Computer Science +
+ +
+
    +
  • GPA: 3.8/4.0, Dean's List
  • +
+
+``` + +### Skills + +Use `` for category labels (NOT markdown `**bold**`): +```html +
+ Languages: Python, JavaScript, TypeScript, Go
+ Frameworks: React, Node.js, FastAPI, Django
+ Tools: PostgreSQL, AWS, Docker, Kubernetes +
+``` + +### Projects +```html +
+ Project Name - Brief description +
    +
  • Technical detail or achievement
  • +
+
+``` + +### Simple Lists (Certifications, Publications) +```html +
    +
  • AWS Certified Solutions Architect, 2023
  • +
  • Doe J. et al., "Title of Paper", Nature 2022 (DOI: 10.1000/xyz123)
  • +
+``` + +For publications: include DOI in parentheses at the end if available. + +## Visual Criteria + +- **Font size:** 11pt-14pt range (body 11pt, name 14pt) - readable at arm's length +- **Margins:** ~0.5in all sides (already set in wrapper) +- **Length:** Single page preferred, 2 pages max +- **Section headers:** Clear uppercase with underline +- **Spacing:** Consistent gaps between entries + +## Layout Guidelines + +1. **One page preferred** - Be concise. Remove less relevant content if needed. +2. **Fill the page** - Aim to use the full page without overflow. +3. **Section order** - Typical order: Summary, Experience, Education, Skills, Projects, Certifications, Publications. Adjust based on relevance to job. +4. **Bullet points** - Max 3-5 per job. Focus on impact, not tasks. Include metrics. +5. **Dates** - Use consistent format (e.g., "Jan 2020" or "2020"). +6. **Entry layout** - Company/title on ONE line with date right-aligned (never stacked). + +## Professional Standards + +### Formatting +- Consistent date format throughout (all "Jan 2020" or all "01/2020", not mixed) +- Parallel structure in bullets (all start with past-tense verbs, or all present-tense) +- No orphan lines (single line alone at top/bottom of page) + +### Language +- No first person ("I", "my", "me") +- Active voice, strong verbs ("Led", "Built", "Reduced" not "Was responsible for") +- No fluff words ("various", "helped with", "assisted in") +- No slang or casual tone + +### Content +- Each bullet has: Action + Context + Result (ideally quantified) +- No generic statements ("team player", "hard worker") +- Specific technologies named, not "various tools" + +### Visual Balance +- Balanced whitespace - no section looks cramped or empty +- Alignment consistent (all dates right-aligned, all bullets same indent) +- No walls of text - max 3 lines per bullet + +## Content Rules + +- Show concrete results with metrics when available +- Feature keywords matching job requirements +- Prioritize experiences most relevant to the role +- Preserve original writing style where possible +- Include all URLs from original resume + +## What NOT to Do + +- Never add ` + +""" + + +def _icon_upload() -> str: + return '' + + +def _icon_arrow() -> str: + return '' + + +def _icon_activity() -> str: + return '' + + +def _icon_download() -> str: + return '' + + +def _selected_model_key(settings: AppSettings) -> str: + for key, option in settings.model_options.items(): + if option.name == settings.model.name: + return key + return next(iter(settings.model_options), "qwen") + + +def _render_model_options(settings: AppSettings, selected_key: str) -> str: + parts = [] + for key, option in settings.model_options.items(): + selected = " selected" if key == selected_key else "" + parts.append(f'') + return "\n".join(parts)