dokster commited on
Commit
7d2fea2
·
verified ·
1 Parent(s): 1c104df

Upload 105 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +29 -0
  2. README.md +64 -10
  3. agents/__init__.py +2 -0
  4. agents/__pycache__/__init__.cpython-311.pyc +0 -0
  5. agents/__pycache__/__init__.cpython-312.pyc +0 -0
  6. agents/__pycache__/ai_generated_detector.cpython-311.pyc +0 -0
  7. agents/__pycache__/ai_generated_detector.cpython-312.pyc +0 -0
  8. agents/__pycache__/combined_reviewer.cpython-311.pyc +0 -0
  9. agents/__pycache__/combined_reviewer.cpython-312.pyc +0 -0
  10. agents/__pycache__/extractor.cpython-311.pyc +0 -0
  11. agents/__pycache__/extractor.cpython-312.pyc +0 -0
  12. agents/__pycache__/hallucination_detector.cpython-311.pyc +0 -0
  13. agents/__pycache__/hallucination_detector.cpython-312.pyc +0 -0
  14. agents/__pycache__/job_parser.cpython-311.pyc +0 -0
  15. agents/__pycache__/job_parser.cpython-312.pyc +0 -0
  16. agents/__pycache__/modal_model.cpython-311.pyc +0 -0
  17. agents/__pycache__/modal_model.cpython-312.pyc +0 -0
  18. agents/__pycache__/name_extractor.cpython-311.pyc +0 -0
  19. agents/__pycache__/name_extractor.cpython-312.pyc +0 -0
  20. agents/__pycache__/optimizer.cpython-311.pyc +0 -0
  21. agents/__pycache__/optimizer.cpython-312.pyc +0 -0
  22. agents/ai_generated_detector.py +60 -0
  23. agents/combined_reviewer.py +111 -0
  24. agents/extractor.py +269 -0
  25. agents/hallucination_detector.py +101 -0
  26. agents/job_parser.py +34 -0
  27. agents/modal_model.py +45 -0
  28. agents/name_extractor.py +36 -0
  29. agents/optimizer.py +226 -0
  30. app.py +12 -0
  31. config.py +9 -0
  32. core/__init__.py +2 -0
  33. core/__pycache__/__init__.cpython-311.pyc +0 -0
  34. core/__pycache__/__init__.cpython-312.pyc +0 -0
  35. core/__pycache__/llm_client.cpython-311.pyc +0 -0
  36. core/__pycache__/llm_client.cpython-312.pyc +0 -0
  37. core/__pycache__/pdf_reader.cpython-311.pyc +0 -0
  38. core/__pycache__/pdf_reader.cpython-312.pyc +0 -0
  39. core/__pycache__/pipeline.cpython-311.pyc +0 -0
  40. core/__pycache__/pipeline.cpython-312.pyc +0 -0
  41. core/__pycache__/prompt_builder.cpython-311.pyc +0 -0
  42. core/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  43. core/__pycache__/renderer.cpython-311.pyc +0 -0
  44. core/__pycache__/renderer.cpython-312.pyc +0 -0
  45. core/llm_client.py +74 -0
  46. core/pdf_reader.py +27 -0
  47. core/pipeline.py +181 -0
  48. core/prompt_builder.py +38 -0
  49. core/renderer.py +91 -0
  50. filters/__init__.py +2 -0
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ libpango-1.0-0 \
11
+ libharfbuzz0b \
12
+ libpangoft2-1.0-0 \
13
+ libglib2.0-0 \
14
+ libgdk-pixbuf-2.0-0 \
15
+ libffi-dev \
16
+ shared-mime-info \
17
+ fonts-dejavu-core \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ COPY requirements.txt .
21
+ RUN pip install --upgrade pip && pip install -r requirements.txt
22
+
23
+ COPY . .
24
+
25
+ RUN mkdir -p output
26
+
27
+ EXPOSE 7860
28
+
29
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,14 +1,68 @@
1
  ---
2
- title: Draftme
3
- emoji: 💻
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
- short_description: Upload CV and get job-optimized resume under 1 minute
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DraftMe
3
+ sdk: docker
4
+ app_port: 7860
 
 
 
 
 
5
  pinned: false
 
6
  ---
7
 
8
+ # DraftMe
9
+
10
+ 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.
11
+
12
+ 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.
13
+
14
+ ## What It Does
15
+
16
+ - Extracts resume content from uploaded PDFs with PyMuPDF
17
+ - Parses job postings into title, company, requirements, keywords, and summary
18
+ - Uses Modal-hosted vLLM endpoints for LLM inference
19
+ - Lets users choose between Qwen and NVIDIA Nemotron backends
20
+ - Runs validation filters for structure, length, hallucination risk, and keyword coverage
21
+ - Shows live workflow telemetry while the resume is being generated
22
+ - Renders the optimized resume to PDF with WeasyPrint
23
+
24
+ ## Models
25
+
26
+ DraftMe currently supports:
27
+
28
+ - Qwen 3.5 27B FP8
29
+ - NVIDIA Nemotron 3 Nano 30B BF16
30
+
31
+ Both models are served through OpenAI-compatible Modal endpoints.
32
+
33
+ ## Stack
34
+
35
+ - Python 3.11
36
+ - Gradio Server with custom HTML, CSS, and JavaScript UI
37
+ - FastAPI / ASGI
38
+ - Pydantic and Pydantic AI
39
+ - OpenAI SDK for Modal vLLM calls
40
+ - PyMuPDF for PDF text extraction
41
+ - WeasyPrint for PDF rendering
42
+ - Jinja2 resume templates
43
+
44
+ ## Local Run
45
+
46
+ ```bash
47
+ uv run --with-requirements requirements.txt uvicorn app:app --host 127.0.0.1 --port 8801
48
+ ```
49
+
50
+ Then open:
51
+
52
+ ```text
53
+ http://127.0.0.1:8801
54
+ ```
55
+
56
+ ## Hugging Face Spaces
57
+
58
+ This project is configured as a Docker Space because it serves a custom ASGI app instead of a standard Gradio Blocks interface.
59
+
60
+ The container starts with:
61
+
62
+ ```bash
63
+ uvicorn app:app --host 0.0.0.0 --port 7860
64
+ ```
65
+
66
+ ## Notes
67
+
68
+ Generated PDFs are written to the local `output/` directory and are ignored during Space uploads.
agents/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """LLM-backed pipeline agents."""
2
+
agents/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (212 Bytes). View file
 
agents/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (159 Bytes). View file
 
agents/__pycache__/ai_generated_detector.cpython-311.pyc ADDED
Binary file (3.49 kB). View file
 
agents/__pycache__/ai_generated_detector.cpython-312.pyc ADDED
Binary file (3.12 kB). View file
 
agents/__pycache__/combined_reviewer.cpython-311.pyc ADDED
Binary file (6.08 kB). View file
 
agents/__pycache__/combined_reviewer.cpython-312.pyc ADDED
Binary file (5.53 kB). View file
 
agents/__pycache__/extractor.cpython-311.pyc ADDED
Binary file (16.7 kB). View file
 
agents/__pycache__/extractor.cpython-312.pyc ADDED
Binary file (14.9 kB). View file
 
agents/__pycache__/hallucination_detector.cpython-311.pyc ADDED
Binary file (5.56 kB). View file
 
agents/__pycache__/hallucination_detector.cpython-312.pyc ADDED
Binary file (5.16 kB). View file
 
agents/__pycache__/job_parser.cpython-311.pyc ADDED
Binary file (1.86 kB). View file
 
agents/__pycache__/job_parser.cpython-312.pyc ADDED
Binary file (1.62 kB). View file
 
agents/__pycache__/modal_model.cpython-311.pyc ADDED
Binary file (2.09 kB). View file
 
agents/__pycache__/modal_model.cpython-312.pyc ADDED
Binary file (1.99 kB). View file
 
agents/__pycache__/name_extractor.cpython-311.pyc ADDED
Binary file (2.13 kB). View file
 
agents/__pycache__/name_extractor.cpython-312.pyc ADDED
Binary file (1.89 kB). View file
 
agents/__pycache__/optimizer.cpython-311.pyc ADDED
Binary file (11.7 kB). View file
 
agents/__pycache__/optimizer.cpython-312.pyc ADDED
Binary file (11.2 kB). View file
 
agents/ai_generated_detector.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from pydantic_ai import Agent
3
+ from pydantic_ai.output import PromptedOutput
4
+
5
+ from agents.modal_model import build_modal_model
6
+ from models.config import AppSettings
7
+ from models.filters import FilterResult
8
+ from models.resume import HTMLResume
9
+
10
+
11
+ class AIGeneratedResult(BaseModel):
12
+ is_ai_generated: bool = Field(description="True if resume appears AI-generated")
13
+ ai_probability: float = Field(ge=0.0, le=1.0)
14
+ indicators: list[str] = Field(default_factory=list)
15
+
16
+
17
+ SYSTEM_PROMPT = """You detect AI-generated content in resumes.
18
+
19
+ CRITICAL: Resumes are INTENTIONALLY formulaic. Every resume guide teaches:
20
+ - Action Verb + Task + Result pattern
21
+ - Consistent bullet structure and length
22
+ - Quantified metrics
23
+ - Industry keywords
24
+ This is GOOD resume writing, NOT AI tells.
25
+
26
+ FLAG ONLY:
27
+ - Fabricated/impossible claims
28
+ - Internal contradictions
29
+ - Buzzword soup with zero specifics
30
+ - Generic filler repeated verbatim
31
+ - Hallucinated details
32
+
33
+ Set is_ai_generated=true ONLY if ai_probability > 0.5.
34
+ When listing indicators, quote specific problematic text.
35
+ """
36
+
37
+
38
+ def detect_ai_generated(resume: HTMLResume | str, settings: AppSettings) -> FilterResult:
39
+ content = resume.html if isinstance(resume, HTMLResume) else resume
40
+ agent = Agent(
41
+ build_modal_model(settings),
42
+ output_type=PromptedOutput(AIGeneratedResult, template="Return JSON matching this schema: {schema}"),
43
+ instructions=SYSTEM_PROMPT,
44
+ )
45
+ result = agent.run_sync(
46
+ "Analyze this resume text for signs of AI generation while ignoring normal resume conventions.\n\n"
47
+ f"=== RESUME TEXT ===\n{content}\n=== END ==="
48
+ )
49
+ output = result.output
50
+ feedback = ""
51
+ if output.indicators:
52
+ feedback = "AI-generation indicators:\n" + "\n".join(f"- {item}" for item in output.indicators)
53
+ return FilterResult(
54
+ filter_name="ai_generated",
55
+ passed=not output.is_ai_generated,
56
+ score=1.0 - output.ai_probability,
57
+ feedback=feedback,
58
+ detail=output.model_dump(mode="json"),
59
+ )
60
+
agents/combined_reviewer.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ import fitz
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_ai import Agent
6
+ from pydantic_ai.output import PromptedOutput
7
+
8
+ from agents.modal_model import build_modal_model
9
+ from models.config import AppSettings
10
+ from models.job import JobPosting
11
+ from models.resume import HTMLResume
12
+
13
+
14
+ class CombinedReviewResult(BaseModel):
15
+ looks_professional: bool = Field(description="True if resume looks professional")
16
+ visual_issues: list[str] = Field(default_factory=list)
17
+ visual_feedback: str = ""
18
+ keyword_score: float = Field(ge=0.0, le=1.0)
19
+ experience_score: float = Field(ge=0.0, le=1.0)
20
+ education_score: float = Field(ge=0.0, le=1.0)
21
+ overall_fit_score: float = Field(ge=0.0, le=1.0)
22
+ disqualified: bool
23
+ ats_issues: list[str] = Field(default_factory=list)
24
+
25
+
26
+ SCORE_WEIGHTS = {
27
+ "keyword": 0.25,
28
+ "experience": 0.175,
29
+ "education": 0.075,
30
+ "overall_fit": 0.50,
31
+ }
32
+
33
+
34
+ SYSTEM_PROMPT = """
35
+ You are TalentScreen ATS v4.2, an enterprise applicant tracking system.
36
+
37
+ Evaluate:
38
+ 1. Resume text/HTML quality and professionalism
39
+ 2. ATS fit against the job posting
40
+
41
+ VISUAL/TEXT QUALITY:
42
+ - Clean organization, readable sections, consistent bullet structure
43
+ - No broken/mangled text, duplicate sections, or obvious formatting artifacts
44
+ - Professional tone, active voice, no slang
45
+
46
+ ATS SCREENING:
47
+ - keyword_score: exact and semantic matches to job requirements/keywords
48
+ - experience_score: work history demonstrates required competencies
49
+ - education_score: education fit if the role requires it
50
+ - overall_fit_score: holistic fit for the role
51
+ - disqualified=true only for strong auto-reject reasons
52
+
53
+ Return all fields.
54
+ """
55
+
56
+
57
+ def pdf_to_image(pdf_bytes: bytes) -> tuple[bytes, int]:
58
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
59
+ try:
60
+ page_count = len(doc)
61
+ page = doc[0]
62
+ pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
63
+ return pix.tobytes("png"), page_count
64
+ finally:
65
+ doc.close()
66
+
67
+
68
+ def combined_review(
69
+ optimized: HTMLResume | str,
70
+ job: JobPosting,
71
+ settings: AppSettings,
72
+ pdf_text: str | None = None,
73
+ ) -> CombinedReviewResult:
74
+ content = optimized.html if isinstance(optimized, HTMLResume) else optimized
75
+ resume_text = pdf_text or _html_to_text(content)
76
+ agent = Agent(
77
+ build_modal_model(settings),
78
+ output_type=PromptedOutput(CombinedReviewResult, template="Return JSON matching this schema: {schema}"),
79
+ instructions=SYSTEM_PROMPT,
80
+ )
81
+ result = agent.run_sync(
82
+ "COMBINED RESUME REVIEW\n\n"
83
+ "=== JOB POSTING ===\n"
84
+ f"Position: {job.title}\n"
85
+ f"Company: {job.company}\n"
86
+ f"Description: {job.description}\n"
87
+ f"Required Skills: {', '.join(job.requirements)}\n"
88
+ f"Keywords: {', '.join(job.keywords)}\n\n"
89
+ "=== RESUME TEXT ===\n"
90
+ f"{resume_text}\n\n"
91
+ "=== RESUME HTML ===\n"
92
+ f"{content[:12000]}"
93
+ )
94
+ return result.output
95
+
96
+
97
+ def compute_ats_score(result: CombinedReviewResult) -> float:
98
+ return (
99
+ result.keyword_score * SCORE_WEIGHTS["keyword"]
100
+ + result.experience_score * SCORE_WEIGHTS["experience"]
101
+ + result.education_score * SCORE_WEIGHTS["education"]
102
+ + result.overall_fit_score * SCORE_WEIGHTS["overall_fit"]
103
+ )
104
+
105
+
106
+ def _html_to_text(html: str) -> str:
107
+ text = re.sub(r"<style.*?</style>", " ", html, flags=re.DOTALL | re.IGNORECASE)
108
+ text = re.sub(r"<script.*?</script>", " ", html, flags=re.DOTALL | re.IGNORECASE)
109
+ text = re.sub(r"<[^>]+>", " ", text)
110
+ return re.sub(r"\s+", " ", text).strip()
111
+
agents/extractor.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Iterable
2
+ import re
3
+ from typing import TypeVar
4
+
5
+ from pydantic import BaseModel
6
+ from pydantic_ai import Agent
7
+ from pydantic_ai.models.openai import OpenAIChatModel
8
+ from pydantic_ai.output import PromptedOutput
9
+
10
+ from agents.modal_model import build_modal_model
11
+ from models.config import AppSettings
12
+ from models.cv import CVData, Contact, Education, Experience, Project, SkillsData, WorkExperience
13
+
14
+
15
+ _SUMMARY_PROMPT = """Extract career summaries from the document.
16
+ Include: professional headlines, "About" / LinkedIn summaries, career objective statements.
17
+ Each distinct paragraph = one list entry. Return [] if none found.
18
+ Do not invent anything not explicitly stated."""
19
+
20
+ _EXPERIENCE_PROMPT = """Extract work experience entries from the document.
21
+ Each distinct role = one entry: employer, title, start date, end date, bullet points.
22
+ Preserve exact dates, company names, and metrics as written.
23
+ Return [] if none found. Do not invent anything."""
24
+
25
+ _EDUCATION_PROMPT = """Extract education entries from the document.
26
+ Each degree or program = one entry: institution, degree, field, start/end dates, notes (GPA, honors, coursework).
27
+ Return [] if none found. Do not invent anything."""
28
+
29
+ _SKILLS_PROMPT = """Extract skills from the document into four categories:
30
+ - technical: programming languages, frameworks, tools, cloud platforms, databases
31
+ - languages: spoken/written human languages (English, Russian, etc.)
32
+ - 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.
33
+ - awards: prizes, honors, recognition
34
+ Return empty lists for absent categories. Do not invent anything."""
35
+
36
+ _PROJECTS_PROMPT = """Extract side projects, open-source work, and research projects.
37
+ Each project: name, short description, URL only if explicitly written in the document, key bullet points.
38
+ Return [] if none found.
39
+ STRICT: Do NOT construct or infer URLs — only copy URLs that appear verbatim in the document text."""
40
+
41
+ _PUBLICATIONS_PROMPT = """Extract publications, papers, patents, and articles.
42
+ 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)".
43
+ Return [] if none found. Do not invent anything.
44
+ 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."""
45
+
46
+ _CONTACT_PROMPT = """Extract personal contact information from the document.
47
+ - name: full name of the candidate as written (first + last)
48
+ - email: email address
49
+ - phone: phone number (any format)
50
+ - linkedin: LinkedIn profile URL (full URL or linkedin.com/in/...)
51
+ - github: GitHub profile URL or username (full URL or github.com/...)
52
+ - website: personal website or portfolio URL
53
+ - other_links: ONLY URLs that are explicitly written in the document verbatim — do NOT construct or infer URLs from names or usernames
54
+
55
+ STRICT RULES:
56
+ - Return null for any field not explicitly present in the document
57
+ - 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)
58
+ - other_links must only contain URLs copied verbatim from the document"""
59
+
60
+ _BASE_INSTRUCTIONS = (
61
+ "You extract structured facts from CV text. Return only data supported by the document. "
62
+ "Do not infer, normalize, summarize beyond the requested shape, or add commentary."
63
+ )
64
+
65
+ T = TypeVar("T")
66
+
67
+
68
+ class SummaryOutput(BaseModel):
69
+ items: list[str] = []
70
+
71
+
72
+ class ExperienceOutput(BaseModel):
73
+ items: list[WorkExperience] = []
74
+
75
+
76
+ class EducationOutput(BaseModel):
77
+ items: list[Education] = []
78
+
79
+
80
+ class ProjectsOutput(BaseModel):
81
+ items: list[Project] = []
82
+
83
+
84
+ class PublicationsOutput(BaseModel):
85
+ items: list[str] = []
86
+
87
+
88
+ def extract(cv_text: str, settings: AppSettings) -> CVData:
89
+ model = _build_model(settings)
90
+
91
+ contact = _run_agent(model, Contact, _CONTACT_PROMPT, cv_text)
92
+ summaries = _run_agent(model, SummaryOutput, _SUMMARY_PROMPT, cv_text).items
93
+ work_entries = _run_agent(model, ExperienceOutput, _EXPERIENCE_PROMPT, cv_text).items
94
+ education = _run_agent(model, EducationOutput, _EDUCATION_PROMPT, cv_text).items
95
+ skills = _run_agent(model, SkillsData, _SKILLS_PROMPT, cv_text)
96
+ projects = _run_agent(model, ProjectsOutput, _PROJECTS_PROMPT, cv_text).items
97
+ publications = _run_agent(model, PublicationsOutput, _PUBLICATIONS_PROMPT, cv_text).items
98
+
99
+ _apply_contact_fallbacks(contact, cv_text)
100
+ name = contact.name or _fallback_name(cv_text)
101
+ return CVData(
102
+ name=name,
103
+ contact=contact,
104
+ summary="\n\n".join(summaries) if summaries else None,
105
+ experience=[_to_legacy_experience(item) for item in work_entries],
106
+ education=education,
107
+ skills=_dedupe(skills.technical),
108
+ certifications=_clean_certifications(skills.certifications, cv_text),
109
+ awards=_dedupe(skills.awards),
110
+ languages=_dedupe(skills.languages),
111
+ projects=projects,
112
+ publications=_clean_publications(publications, cv_text),
113
+ raw_text=cv_text,
114
+ )
115
+
116
+
117
+ def _build_model(settings: AppSettings) -> OpenAIChatModel:
118
+ return build_modal_model(settings)
119
+
120
+
121
+ def _run_agent(model: OpenAIChatModel, output_type: type[T], prompt: str, cv_text: str) -> T:
122
+ agent = Agent(
123
+ model,
124
+ output_type=PromptedOutput(output_type, template="Return JSON matching this schema: {schema}"),
125
+ instructions=f"{_BASE_INSTRUCTIONS}\n\n{prompt}",
126
+ )
127
+ result = agent.run_sync(f"Document text:\n\n{cv_text}")
128
+ return result.output
129
+
130
+
131
+ def _to_legacy_experience(item: WorkExperience) -> Experience:
132
+ return Experience(
133
+ company=item.employer or "",
134
+ title=item.title or "",
135
+ start=item.start_date or "",
136
+ end=item.end_date,
137
+ bullets=item.bullet_points,
138
+ )
139
+
140
+
141
+ def _fallback_name(cv_text: str) -> str:
142
+ for line in cv_text.splitlines():
143
+ candidate = line.strip()
144
+ if candidate:
145
+ return candidate[:120]
146
+ return "Unknown Candidate"
147
+
148
+
149
+ def _apply_contact_fallbacks(contact: Contact, cv_text: str) -> None:
150
+ if not contact.email:
151
+ match = re.search(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+", cv_text)
152
+ if match:
153
+ contact.email = match.group(0)
154
+ if not contact.linkedin:
155
+ match = re.search(r"(?:https?://)?(?:www\.)?linkedin\.com/in/[^\s|,;]+", cv_text, re.IGNORECASE)
156
+ if match:
157
+ contact.linkedin = match.group(0)
158
+ if not contact.github:
159
+ match = re.search(r"(?:https?://)?(?:www\.)?github\.com/[^\s|,;]+", cv_text, re.IGNORECASE)
160
+ if match:
161
+ contact.github = match.group(0)
162
+ if not contact.website:
163
+ urls = re.findall(r"https?://[^\s|,;]+", cv_text)
164
+ known = {value for value in (contact.linkedin, contact.github) if value}
165
+ for url in urls:
166
+ if url not in known:
167
+ contact.website = url
168
+ break
169
+
170
+
171
+ def _clean_publications(items: Iterable[str], cv_text: str) -> list[str]:
172
+ if not (_has_section_heading(cv_text, ("publications", "publication", "papers", "patents")) or _has_publication_identifier(cv_text)):
173
+ return []
174
+
175
+ publications: list[str] = []
176
+ for item in _dedupe(items):
177
+ if _looks_like_experience_bullet(item):
178
+ continue
179
+ if _has_publication_identifier(item) or _looks_like_publication_citation(item):
180
+ publications.append(item)
181
+ return publications
182
+
183
+
184
+ def _clean_certifications(items: Iterable[str], cv_text: str) -> list[str]:
185
+ has_cert_section = _has_section_heading(cv_text, ("certifications", "certification", "certificates", "licenses"))
186
+ certifications: list[str] = []
187
+ for item in _dedupe(items):
188
+ if _looks_like_experience_bullet(item):
189
+ continue
190
+ if _looks_like_certification(item) or has_cert_section and _looks_like_short_named_item(item):
191
+ certifications.append(item)
192
+ return certifications
193
+
194
+
195
+ def _has_section_heading(text: str, headings: tuple[str, ...]) -> bool:
196
+ for line in text.splitlines():
197
+ normalized = re.sub(r"[^a-z]+", " ", line.lower()).strip()
198
+ if normalized in headings:
199
+ return True
200
+ return False
201
+
202
+
203
+ def _has_publication_identifier(text: str) -> bool:
204
+ lowered = text.lower()
205
+ return bool(
206
+ re.search(r"\bdoi\s*:\s*10\.\S+", lowered)
207
+ or re.search(r"\b10\.\d{4,9}/\S+", lowered)
208
+ or re.search(r"\barxiv\s*:?\s*\d", lowered)
209
+ or re.search(r"\bpatent(?:s|ed)?\b", lowered)
210
+ )
211
+
212
+
213
+ def _looks_like_publication_citation(item: str) -> bool:
214
+ lowered = item.lower()
215
+ if any(word in lowered for word in ("journal", "conference", "proceedings", "transactions", "published", "publication")):
216
+ return True
217
+ return bool(re.search(r"\b(?:19|20)\d{2}\b", item) and re.search(r"[“\"].+[”\"]", item))
218
+
219
+
220
+ def _looks_like_certification(item: str) -> bool:
221
+ lowered = item.lower()
222
+ certification_markers = (
223
+ "certified",
224
+ "certification",
225
+ "certificate",
226
+ "license",
227
+ "licence",
228
+ "credential",
229
+ "pmp",
230
+ "cpa",
231
+ "cfa",
232
+ "ccna",
233
+ "cissp",
234
+ "aws certified",
235
+ "azure certified",
236
+ "google cloud certified",
237
+ )
238
+ return any(marker in lowered for marker in certification_markers) and _looks_like_short_named_item(item)
239
+
240
+
241
+ def _looks_like_short_named_item(item: str) -> bool:
242
+ words = item.split()
243
+ return 1 <= len(words) <= 12 and len(item) <= 120
244
+
245
+
246
+ def _looks_like_experience_bullet(item: str) -> bool:
247
+ normalized = item.strip()
248
+ lowered = normalized.lower()
249
+ if not normalized:
250
+ return False
251
+ if len(normalized.split()) > 14:
252
+ return True
253
+ if re.search(r"\b(?:built|developed|created|implemented|managed|led|improved|deployed|maintained|designed|worked|processed|optimized|reduced|increased|delivered|collaborated)\b", lowered):
254
+ return True
255
+ 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):
256
+ return True
257
+ return False
258
+
259
+
260
+ def _dedupe(items: Iterable[str]) -> list[str]:
261
+ values: list[str] = []
262
+ seen: set[str] = set()
263
+ for item in items:
264
+ normalized = " ".join(item.split())
265
+ key = normalized.lower()
266
+ if normalized and key not in seen:
267
+ values.append(normalized)
268
+ seen.add(key)
269
+ return values
agents/hallucination_detector.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from pydantic_ai import Agent
3
+ from pydantic_ai.output import PromptedOutput
4
+
5
+ from agents.modal_model import build_modal_model
6
+ from models.config import AppSettings
7
+ from models.filters import FilterResult
8
+ from models.resume import HTMLResume
9
+
10
+
11
+ class HallucinationResult(BaseModel):
12
+ no_hallucination_score: float = Field(
13
+ ge=0.0,
14
+ le=1.0,
15
+ description="Score from 0 to 1 where 1.0 = no fabrications, 0.0 = severe fabrications",
16
+ )
17
+ concerns: list[str] = Field(default_factory=list)
18
+ reasoning: str = ""
19
+
20
+
21
+ STRICT_PROMPT = """You are a resume verification specialist.
22
+ Compare an ORIGINAL resume with an OPTIMIZED version and return a no_hallucination_score from 0.0 to 1.0.
23
+
24
+ SCORING GUIDE:
25
+ - 1.0: Perfect - all content traceable to original, only rephrasing/restructuring
26
+ - 0.9-0.99: Minor acceptable additions (related tech inference, umbrella terms)
27
+ - 0.8-0.9: Light assumptions that are reasonable but noticeable
28
+ - 0.7-0.8: Questionable additions - somewhat plausible but stretching
29
+ - 0.5-0.69: Significant fabrications - claims that may not be true
30
+ - 0.0-0.49: Severe fabrications - fake jobs, degrees, major false claims
31
+
32
+ SERIOUS FABRICATIONS (score below 0.5):
33
+ - Fabricated job titles, companies, or employment dates
34
+ - Invented degrees, certifications, or institutions
35
+ - Made-up metrics with specific numbers not in original
36
+ - Fake achievements, publications, or awards
37
+ - Completely unrelated technologies
38
+ """
39
+
40
+ LENIENT_PROMPT = """You are a resume verification specialist.
41
+ Compare an ORIGINAL resume with an OPTIMIZED version and return a no_hallucination_score from 0.0 to 1.0.
42
+
43
+ SCORING GUIDE:
44
+ - 1.0: All content directly traceable to original
45
+ - 0.8-0.99: Aggressive skill extrapolations that are plausible from context
46
+ - 0.6-0.79: Significant embellishment of achievements, creative reframing
47
+ - 0.5-0.59: Very aggressive stretching but still plausible
48
+ - 0.0-0.49: Blatant fabrications - fake jobs, degrees, made-up credentials
49
+
50
+ ACCEPTABLE (score 0.7+):
51
+ - Aggressive technology extrapolation: Python user -> any Python library, web dev -> full stack
52
+ - Adding plausible tools from job context even if not explicitly stated
53
+ - Creative reframing of responsibilities to match job requirements
54
+ - Inferring leadership/mentoring from senior roles
55
+ - Adding industry-standard practices plausible for their role
56
+
57
+ BLOCK (score below 0.5):
58
+ - Fabricated job titles, companies, or employment dates
59
+ - Invented degrees, certifications, or institutions
60
+ - Made-up awards, publications, or patents
61
+ - Completely fictional projects or achievements
62
+ - Technologies with zero connection to stated experience
63
+ - Made up specific metrics
64
+ """
65
+
66
+
67
+ def detect_hallucinations(
68
+ optimized: HTMLResume | str,
69
+ original_text: str,
70
+ settings: AppSettings,
71
+ job_text: str = "",
72
+ no_shame: bool = True,
73
+ ) -> FilterResult:
74
+ optimized_content = optimized.html if isinstance(optimized, HTMLResume) else optimized
75
+ threshold = 0.5 if no_shame else 0.9
76
+ prompt = LENIENT_PROMPT if no_shame else STRICT_PROMPT
77
+ agent = Agent(
78
+ build_modal_model(settings),
79
+ output_type=PromptedOutput(HallucinationResult, template="Return JSON matching this schema: {schema}"),
80
+ instructions=prompt,
81
+ )
82
+ result = agent.run_sync(
83
+ "Compare these two resumes and score the optimized version for hallucinations.\n\n"
84
+ f"=== ORIGINAL RESUME ===\n{original_text}\n\n"
85
+ f"=== JOB POSTING CONTEXT ===\n{job_text}\n\n"
86
+ f"=== OPTIMIZED RESUME ===\n{optimized_content}"
87
+ )
88
+ output = result.output
89
+ passed = output.no_hallucination_score >= threshold
90
+ feedback = ""
91
+ if not passed:
92
+ concerns = "\n".join(f"- {item}" for item in output.concerns)
93
+ feedback = f"Score {output.no_hallucination_score:.2f} below {threshold:.2f}. {output.reasoning}\n{concerns}".strip()
94
+ return FilterResult(
95
+ filter_name="hallucination",
96
+ passed=passed,
97
+ score=output.no_hallucination_score,
98
+ feedback=feedback,
99
+ detail=output.model_dump(mode="json") | {"threshold": threshold},
100
+ )
101
+
agents/job_parser.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_ai import Agent
2
+ from pydantic_ai.models.openai import OpenAIChatModel
3
+ from pydantic_ai.output import PromptedOutput
4
+
5
+ from agents.modal_model import build_modal_model
6
+ from models.config import AppSettings
7
+ from models.job import JobPosting
8
+
9
+
10
+ SYSTEM_PROMPT = """You are a job posting parser. Extract structured information from job postings.
11
+
12
+ Extract:
13
+ - title: The job title
14
+ - company: Company name
15
+ - requirements: List of specific requirements (skills, experience, education)
16
+ - keywords: Technical keywords, tools, technologies mentioned
17
+ - description: Brief summary of the role
18
+
19
+ Be thorough in extracting keywords - include all technologies, tools, frameworks, methodologies mentioned.
20
+ """
21
+
22
+
23
+ def parse_job_posting(job_text: str, settings: AppSettings) -> JobPosting:
24
+ agent = Agent(
25
+ _build_model(settings),
26
+ output_type=PromptedOutput(JobPosting, template="Return JSON matching this schema: {schema}"),
27
+ instructions=SYSTEM_PROMPT,
28
+ )
29
+ result = agent.run_sync(f"Job posting text:\n\n{job_text}")
30
+ return result.output
31
+
32
+
33
+ def _build_model(settings: AppSettings) -> OpenAIChatModel:
34
+ return build_modal_model(settings)
agents/modal_model.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openai import AsyncOpenAI
2
+ from pydantic_ai.models.openai import OpenAIChatModel
3
+ from pydantic_ai.providers.openai import OpenAIProvider
4
+
5
+ from models.config import AppSettings
6
+
7
+
8
+ def build_modal_model(settings: AppSettings) -> OpenAIChatModel:
9
+ is_qwen = settings.model.name.lower().startswith("qwen")
10
+ client = AsyncOpenAI(
11
+ base_url=settings.model.base_url,
12
+ api_key=settings.model.api_key,
13
+ timeout=180,
14
+ max_retries=1,
15
+ )
16
+ return OpenAIChatModel(
17
+ settings.model.name,
18
+ provider=OpenAIProvider(openai_client=client),
19
+ settings=_modal_model_settings(settings),
20
+ system_prompt_role="user" if is_qwen else None,
21
+ )
22
+
23
+
24
+ def _modal_model_settings(settings: AppSettings) -> dict:
25
+ model_name = settings.model.name.lower()
26
+ model_settings: dict = {
27
+ "temperature": settings.model.temperature,
28
+ "max_tokens": settings.model.max_tokens,
29
+ }
30
+ if model_name.startswith("qwen"):
31
+ model_settings.update(
32
+ {
33
+ "top_p": 0.8,
34
+ "presence_penalty": 1.5,
35
+ "extra_body": {
36
+ "top_k": 20,
37
+ "chat_template_kwargs": {"enable_thinking": False},
38
+ },
39
+ }
40
+ )
41
+ elif "nemotron" in model_name:
42
+ model_settings["extra_body"] = {
43
+ "chat_template_kwargs": {"enable_thinking": False},
44
+ }
45
+ return model_settings
agents/name_extractor.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from pydantic_ai import Agent
3
+ from pydantic_ai.output import PromptedOutput
4
+
5
+ from agents.modal_model import build_modal_model
6
+ from models.config import AppSettings
7
+
8
+
9
+ class ExtractedName(BaseModel):
10
+ first_name: str | None = None
11
+ last_name: str | None = None
12
+ language_code: str = "en"
13
+
14
+
15
+ SYSTEM_PROMPT = """Extract the person's name from this resume/CV content.
16
+
17
+ Return:
18
+ - first_name: The person's first/given name
19
+ - last_name: The person's last/family name (may include middle names)
20
+
21
+ If you cannot find a name, return null for both fields.
22
+ Handle any format: LaTeX, plain text, markdown, HTML, etc.
23
+ Ignore formatting commands - extract the actual name text only.
24
+ """
25
+
26
+
27
+ def extract_name(content: str, settings: AppSettings) -> tuple[str | None, str | None, str]:
28
+ agent = Agent(
29
+ build_modal_model(settings),
30
+ output_type=PromptedOutput(ExtractedName, template="Return JSON matching this schema: {schema}"),
31
+ instructions=SYSTEM_PROMPT,
32
+ )
33
+ result = agent.run_sync(f"Extract the name from this resume:\n\n{content[:3000]}")
34
+ output = result.output
35
+ return output.first_name, output.last_name, output.language_code
36
+
agents/optimizer.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic_ai import Agent
8
+ from pydantic_ai.models.openai import OpenAIChatModel
9
+ from pydantic_ai.output import PromptedOutput
10
+
11
+ from agents.modal_model import build_modal_model
12
+ from models.config import AppSettings
13
+ from models.cv import CVData
14
+ from models.resume import HTMLResume
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
19
+
20
+
21
+ class OptimizerResult(BaseModel):
22
+ html: str
23
+ changes: list[str] = []
24
+
25
+
26
+ def _load_resume_guide() -> str:
27
+ guide_path = TEMPLATE_DIR / "resume_guide.md"
28
+ if not guide_path.exists():
29
+ return ""
30
+ return guide_path.read_text(encoding="utf-8")
31
+
32
+
33
+ OPTIMIZER_BASE = r"""
34
+ You are a resume optimization expert. Use the parsed resume data and create optimized HTML for a job posting.
35
+
36
+ INPUT: Parsed candidate resume JSON and job posting text.
37
+
38
+ OUTPUT: Generate HTML for the <body> of a resume PDF. Do NOT include <html>, <head>, or <body> tags - only the body content.
39
+
40
+ CONTENT RULES:
41
+ - When describing job experiences, show concrete results: focus on impact, not tasks.
42
+ - Include specific technologies within achievement descriptions.
43
+ - 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").
44
+ - Prioritize and highlight experiences most relevant to the role.
45
+ - If going over one page: remove unrelated content to save space.
46
+ - Remove obvious skills (Excel, VS Code, Jupyter, GitHub, Jira) unless specifically required by job or very relevant to it.
47
+ - Exclude: location, language proficiency, age, hobbies unless required by job posting.
48
+ - Add a summary section highlighting the most relevant experiences.
49
+ - Try to preserve the original writing style if possible.
50
+ - Avoid leaving empty space at the bottom of the page if useful relevant content can fill it.
51
+ - PROJECTS: Only include projects directly relevant to this job. Skip projects already listed under Publications. If no projects are relevant, omit the section.
52
+ - PUBLICATIONS: Always use "PUBLICATIONS" as the section title when publications are present.
53
+ - EDUCATION: By default include only the most recent / highest degree. Include multiple degrees only if both are relevant.
54
+
55
+ {content_rules}
56
+
57
+ CONTENT BUDGET:
58
+ - Target: about 500 words and about 4000 characters.
59
+ - The pipeline will validate length, structure, keyword coverage, hallucination risk, and renderability after you return.
60
+ - If previous feedback is provided, make the smallest possible change to address that feedback.
61
+
62
+ LINKS:
63
+ - Preserve contact info from the original and never delete it.
64
+ - Preserve URLs from the original resume: email, LinkedIn, GitHub, website, project links.
65
+ - Use full URLs (include https://) in the href attribute of every <a> tag.
66
+ - Link display text must NOT start with https:// or http://. Show just the domain+path.
67
+
68
+ PUBLICATIONS:
69
+ - Always append the DOI in parentheses at the end if available, e.g. "Author et al., Title, Venue Year (DOI: 10.xxxx/xxxx)".
70
+
71
+ TEMPLATE AND CSS:
72
+ - Use the provided template guide and CSS classes exactly where possible.
73
+ - 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.
74
+ - You MUST include a header with the candidate name and available contact links.
75
+ - Do not emit Markdown.
76
+ - Do not emit wrapper tags.
77
+ - 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.
78
+ - For education notes, include GPA, honors, coursework, or awards ONLY if they appear in the parsed resume JSON or original resume text.
79
+
80
+ {resume_guide}
81
+ """
82
+
83
+ OPTIMIZER_STRICT_RULES = """
84
+ ALLOWED:
85
+ - You CAN add related technologies plausible from context (e.g. Python user likely knows pip, venv; React user likely knows npm, webpack).
86
+ - General/umbrella terms inferable from context: "NLP" if they did text processing, "SQL" if they used databases.
87
+ - Rephrasing metrics with same values: "1% - 10%" -> "1-10%", "$10k" -> "$10,000".
88
+ - Reordering and emphasizing existing content.
89
+
90
+ STRICT RULES - NEVER VIOLATE:
91
+ - 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.
92
+ - NEVER fabricate job titles, companies, degrees, certifications, achievements, publications, patents, awards, or projects.
93
+ - NEVER copy example facts from the template guide into the candidate resume.
94
+ - NEVER invent metrics, numbers, and achievements not in original.
95
+ - Do NOT drop critical work experience or achievements unless they decrease fit.
96
+ - Never use the em dash symbol, the word "delve", or other common markers of LLM-generated text.
97
+ - NEVER add <script> tags.
98
+ - Do not cut critical content if you can cut lower-value content like summary.
99
+ """
100
+
101
+ OPTIMIZER_LENIENT_RULES = """
102
+ ALLOWED:
103
+ - You CAN add related technologies plausible from context (e.g. Python user likely knows pip, venv; React user likely knows npm, webpack).
104
+ - You CAN extrapolate skills from adjacent experience.
105
+ - You CAN make light assumptions about the candidate.
106
+ - General/umbrella terms inferable from context: "NLP" if they did text processing, "SQL" if they used databases.
107
+ - Rephrasing metrics with same values: "1% - 10%" -> "1-10%".
108
+ - Reordering and emphasizing existing content.
109
+
110
+ STRICT RULES - NEVER VIOLATE:
111
+ - NEVER fabricate job titles, companies, degrees, certifications, or achievements.
112
+ - NEVER copy example facts from the template guide into the candidate resume.
113
+ - NEVER invent metrics, numbers, and achievements not in original.
114
+ - Never use the em dash symbol, the word "delve", or other common markers of LLM-generated text.
115
+ - NEVER add <script> tags.
116
+ - Do not cut critical content if you can cut lower-value content like summary.
117
+ """
118
+
119
+
120
+ def optimize(
121
+ cv_data: CVData,
122
+ jd_text: str,
123
+ feedback: str,
124
+ iteration: int,
125
+ client,
126
+ settings: AppSettings,
127
+ ) -> HTMLResume:
128
+ agent = Agent(
129
+ _build_model(settings),
130
+ output_type=PromptedOutput(OptimizerResult, template="Return JSON matching this schema: {schema}"),
131
+ instructions=_build_system_prompt(settings),
132
+ )
133
+ result = agent.run_sync(_build_user_prompt(cv_data, jd_text, feedback, iteration, settings)).output
134
+ html = _sanitize_template_examples(result.html, cv_data.raw_text, jd_text)
135
+ return HTMLResume(
136
+ html=html,
137
+ iteration=iteration,
138
+ model_used=settings.model.name,
139
+ changes=result.changes,
140
+ )
141
+
142
+
143
+ def _build_model(settings: AppSettings) -> OpenAIChatModel:
144
+ return build_modal_model(settings)
145
+
146
+
147
+ def _build_system_prompt(settings: AppSettings) -> str:
148
+ content_rules = OPTIMIZER_LENIENT_RULES
149
+ return OPTIMIZER_BASE.format(content_rules=content_rules, resume_guide=_load_resume_guide())
150
+
151
+
152
+ def _build_user_prompt(
153
+ cv_data: CVData,
154
+ jd_text: str,
155
+ feedback: str,
156
+ iteration: int,
157
+ settings: AppSettings,
158
+ ) -> str:
159
+ prompt = f"""Today's date: {date.today().strftime('%B %Y')}
160
+
161
+ ## Parsed Resume JSON:
162
+ {cv_data.model_dump_json(indent=2)}
163
+
164
+ ## Original Resume Text:
165
+ {cv_data.raw_text}
166
+
167
+ ## Job Posting:
168
+ {jd_text}
169
+
170
+ ## Target:
171
+ - Tone: {settings.tone}
172
+ - Language: {settings.language}
173
+ - Iteration: {iteration}
174
+ """
175
+ if feedback:
176
+ prompt += f"""
177
+ ## Previous Validation Feedback:
178
+ {feedback}
179
+
180
+ IMPORTANT: This is a refinement iteration. Make the smallest possible change to pass failed filters.
181
+ - Do NOT rewrite from scratch.
182
+ - Change only what is needed to pass the failed filters.
183
+ - Preserve everything that already works.
184
+ """
185
+ prompt += """
186
+ Return JSON with:
187
+ - html: resume body HTML only, no <html>, <head>, or <body>
188
+ - changes: short list of changes made
189
+ """
190
+ return prompt
191
+
192
+
193
+ def _sanitize_template_examples(html: str, source_text: str, jd_text: str) -> str:
194
+ evidence = f"{source_text}\n{jd_text}".lower()
195
+ cleaned = html
196
+
197
+ if "gpa" not in evidence and "dean" not in evidence:
198
+ cleaned = re.sub(
199
+ r"\s*<li>\s*GPA:\s*3\.8/4\.0,\s*Dean[’']s List\s*</li>",
200
+ "",
201
+ cleaned,
202
+ flags=re.IGNORECASE,
203
+ )
204
+
205
+ guide_only_terms = [
206
+ "JavaScript",
207
+ "TypeScript",
208
+ "Go",
209
+ "React",
210
+ "Node.js",
211
+ "Django",
212
+ "Kubernetes",
213
+ ]
214
+ for term in guide_only_terms:
215
+ if term.lower() not in evidence:
216
+ cleaned = re.sub(rf"(?<![A-Za-z0-9.+#-]){re.escape(term)}(?![A-Za-z0-9.+#-])", "", cleaned)
217
+
218
+ cleaned = re.sub(r",\s*,+", ",", cleaned)
219
+ cleaned = re.sub(r":\s*,\s*", ": ", cleaned)
220
+ cleaned = re.sub(r",\s*(<br>|</)", r"\1", cleaned)
221
+ cleaned = re.sub(r"<ul class=\"bullets\">\s*</ul>", "", cleaned)
222
+ cleaned = re.sub(r"<ul class='bullets'>\s*</ul>", "", cleaned)
223
+ cleaned = re.sub(r"<ul class=\"simple-list\">\s*</ul>", "", cleaned)
224
+ cleaned = re.sub(r"<ul class='simple-list'>\s*</ul>", "", cleaned)
225
+ cleaned = re.sub(r"\s{2,}", " ", cleaned)
226
+ return cleaned
app.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ from config import get_settings
4
+ from ui.server import create_app
5
+
6
+ settings = get_settings()
7
+ app = create_app(settings)
8
+
9
+
10
+ if __name__ == "__main__":
11
+ uvicorn.run("app:app", reload=False)
12
+
config.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from models.config import AppSettings
4
+
5
+
6
+ @lru_cache
7
+ def get_settings() -> AppSettings:
8
+ return AppSettings()
9
+
core/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Core runtime modules for EZ JOB."""
2
+
core/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (215 Bytes). View file
 
core/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (162 Bytes). View file
 
core/__pycache__/llm_client.cpython-311.pyc ADDED
Binary file (5.04 kB). View file
 
core/__pycache__/llm_client.cpython-312.pyc ADDED
Binary file (4.64 kB). View file
 
core/__pycache__/pdf_reader.cpython-311.pyc ADDED
Binary file (2.42 kB). View file
 
core/__pycache__/pdf_reader.cpython-312.pyc ADDED
Binary file (1.89 kB). View file
 
core/__pycache__/pipeline.cpython-311.pyc ADDED
Binary file (10.6 kB). View file
 
core/__pycache__/pipeline.cpython-312.pyc ADDED
Binary file (9.31 kB). View file
 
core/__pycache__/prompt_builder.cpython-311.pyc ADDED
Binary file (1.72 kB). View file
 
core/__pycache__/prompt_builder.cpython-312.pyc ADDED
Binary file (1.55 kB). View file
 
core/__pycache__/renderer.cpython-311.pyc ADDED
Binary file (5.92 kB). View file
 
core/__pycache__/renderer.cpython-312.pyc ADDED
Binary file (5.21 kB). View file
 
core/llm_client.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from pathlib import Path
3
+ from typing import TypeVar
4
+
5
+ from openai import OpenAI
6
+ from pydantic import BaseModel, ValidationError
7
+
8
+ from models.config import ModelSettings
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+
13
+ class LLMClient:
14
+ def __init__(self, settings: ModelSettings, debug: bool = False, debug_dir: Path | None = None):
15
+ self.client = OpenAI(base_url=settings.base_url, api_key=settings.api_key)
16
+ self.settings = settings
17
+ self.debug = debug
18
+ self.debug_dir = debug_dir
19
+
20
+ def chat(self, messages: list[dict], schema: type[BaseModel] | None = None) -> str:
21
+ extra_body = _extra_body(self.settings, schema)
22
+ response = self.client.chat.completions.create(
23
+ model=self.settings.name,
24
+ messages=messages,
25
+ temperature=self.settings.temperature,
26
+ max_tokens=self.settings.max_tokens,
27
+ extra_body=extra_body,
28
+ )
29
+ content = response.choices[0].message.content or ""
30
+ if self.debug and self.debug_dir:
31
+ self.debug_dir.mkdir(parents=True, exist_ok=True)
32
+ count = len(list(self.debug_dir.glob("llm_response_*.txt")))
33
+ (self.debug_dir / f"llm_response_{count + 1}.txt").write_text(content, encoding="utf-8")
34
+ return content
35
+
36
+ def chat_structured(self, messages: list[dict], schema: type[T]) -> T:
37
+ last_error: Exception | None = None
38
+ working_messages = list(messages)
39
+ for _ in range(2):
40
+ raw = self.chat(working_messages, schema=schema)
41
+ cleaned = _strip_markdown_fences(raw)
42
+ try:
43
+ return schema.model_validate_json(cleaned)
44
+ except ValidationError as exc:
45
+ last_error = exc
46
+ working_messages.append(
47
+ {
48
+ "role": "user",
49
+ "content": (
50
+ "The previous response did not match the required JSON schema. "
51
+ f"Validation error: {exc}. Return only valid JSON."
52
+ ),
53
+ }
54
+ )
55
+ raise last_error or ValueError("Structured LLM response could not be parsed")
56
+
57
+
58
+ def _extra_body(settings: ModelSettings, schema: type[BaseModel] | None = None) -> dict | None:
59
+ extra: dict = {}
60
+ if schema:
61
+ extra["format"] = schema.model_json_schema()
62
+ model_name = settings.name.lower()
63
+ if model_name.startswith("qwen"):
64
+ extra["top_k"] = 20
65
+ extra["chat_template_kwargs"] = {"enable_thinking": False}
66
+ elif "nemotron" in model_name:
67
+ extra["chat_template_kwargs"] = {"enable_thinking": False}
68
+ return extra or None
69
+
70
+
71
+ def _strip_markdown_fences(text: str) -> str:
72
+ stripped = text.strip()
73
+ match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, re.DOTALL | re.IGNORECASE)
74
+ return match.group(1).strip() if match else stripped
core/pdf_reader.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import tempfile
3
+ import unicodedata
4
+ from pathlib import Path
5
+
6
+ import fitz
7
+
8
+
9
+ def read_pdf(source: bytes | str | Path) -> tuple[str, int]:
10
+ if isinstance(source, bytes):
11
+ with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
12
+ tmp.write(source)
13
+ tmp.flush()
14
+ return _read_pdf_path(Path(tmp.name))
15
+ return _read_pdf_path(Path(source))
16
+
17
+
18
+ def _read_pdf_path(path: Path) -> tuple[str, int]:
19
+ with fitz.open(path) as pdf:
20
+ pages = [page.get_text("text") or "" for page in pdf]
21
+ text = "\n\n".join(pages)
22
+ text = unicodedata.normalize("NFKC", text)
23
+ text = re.sub(r"[ \t]+", " ", text)
24
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
25
+ if not text:
26
+ raise ValueError("No extractable text found in PDF. The file may be scanned.")
27
+ return text, len(pages)
core/pipeline.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from collections.abc import Generator
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from agents.extractor import extract
7
+ from agents.optimizer import optimize
8
+ from core.llm_client import LLMClient
9
+ from core.pdf_reader import read_pdf
10
+ from core.renderer import render_pdf
11
+ from filters.content_length import ContentLengthFilter
12
+ from filters.hallucination import HallucinationFilter
13
+ from filters.keyword import KeywordFilter
14
+ from filters.runner import run_all
15
+ from filters.structure import StructureFilter
16
+ from models.config import AppSettings
17
+ from models.pipeline import PipelineResult, StatusEvent
18
+ from models.resume import HTMLResume
19
+
20
+
21
+ def run_pipeline(
22
+ cv_bytes: bytes,
23
+ cv_filename: str,
24
+ jd_text: str,
25
+ settings: AppSettings,
26
+ ) -> Generator[StatusEvent, None, PipelineResult]:
27
+ start = time.time()
28
+ trace_id = datetime.now().strftime("%Y%m%d-%H%M%S")
29
+ debug_dir = settings.output_dir / "debug" if settings.debug else None
30
+ client = LLMClient(settings.model, debug=settings.debug, debug_dir=debug_dir)
31
+ validation_filters = [ContentLengthFilter(), StructureFilter(), HallucinationFilter(), KeywordFilter()]
32
+
33
+ try:
34
+ yield _trace(trace_id, start, StatusEvent(step="extract", message=f"Starting workflow for {cv_filename}"))
35
+ yield _trace(trace_id, start, StatusEvent(step="extract", message="Extracting text from PDF..."))
36
+ cv_text, _ = read_pdf(cv_bytes)
37
+ _trace_line(trace_id, start, "extract", f"Extracted {len(cv_text)} characters from PDF")
38
+
39
+ yield _trace(trace_id, start, StatusEvent(step="extract", message="Parsing CV structure..."))
40
+ cv_data = extract(cv_text, settings)
41
+ cv_data.raw_text = cv_text
42
+ _trace_line(
43
+ trace_id,
44
+ start,
45
+ "extract",
46
+ (
47
+ f"Parsed CV: name={cv_data.name or 'unknown'}, "
48
+ f"experience={len(cv_data.experience)}, education={len(cv_data.education)}, "
49
+ f"skills={len(cv_data.skills)}, projects={len(cv_data.projects)}, "
50
+ f"publications={len(cv_data.publications)}"
51
+ ),
52
+ )
53
+
54
+ feedback = ""
55
+ html_resume: HTMLResume | None = None
56
+ report = None
57
+ attempts_used = 0
58
+
59
+ for iteration in range(settings.max_iterations):
60
+ attempts_used = iteration + 1
61
+ yield _trace(
62
+ trace_id,
63
+ start,
64
+ StatusEvent(
65
+ step="optimize",
66
+ iteration=iteration,
67
+ message=f"Generating resume (attempt {attempts_used}/{settings.max_iterations})...",
68
+ ),
69
+ )
70
+ html_resume = optimize(cv_data, jd_text, feedback, iteration, client, settings)
71
+ _trace_line(trace_id, start, "optimize", f"Generated {len(html_resume.html)} HTML characters")
72
+
73
+ yield _trace(trace_id, start, StatusEvent(step="filter", iteration=iteration, message="Running validation filters..."))
74
+ report = run_all(html_resume, cv_data, jd_text, validation_filters, settings)
75
+ _trace_filter_report(trace_id, start, report)
76
+
77
+ if report.hard_failed:
78
+ yield _trace(trace_id, start, StatusEvent(step="error", message="Hallucination detected; aborting."))
79
+ return PipelineResult(
80
+ success=False,
81
+ error="Hallucination detected",
82
+ filter_report=report,
83
+ iterations_used=attempts_used,
84
+ debug_dir=debug_dir,
85
+ )
86
+
87
+ if report.all_passed:
88
+ yield _trace(trace_id, start, StatusEvent(step="filter", iteration=iteration, message="All filters passed."))
89
+ break
90
+
91
+ feedback = report.combined_feedback
92
+ yield _trace(trace_id, start, StatusEvent(step="filter", iteration=iteration, message="Filters failed; retrying with feedback..."))
93
+
94
+ if html_resume is None:
95
+ raise RuntimeError("Resume generation did not produce HTML.")
96
+
97
+ if not report or not report.all_passed:
98
+ error = "Validation filters did not pass; PDF render skipped."
99
+ yield _trace(trace_id, start, StatusEvent(step="error", message=error))
100
+ return PipelineResult(
101
+ success=False,
102
+ error=error,
103
+ filter_report=report,
104
+ iterations_used=attempts_used,
105
+ debug_dir=debug_dir,
106
+ )
107
+
108
+ yield _trace(trace_id, start, StatusEvent(step="render", message="Rendering PDF..."))
109
+ duration = time.time() - start
110
+ pdf_path = render_pdf(
111
+ html_resume,
112
+ cv_data,
113
+ settings,
114
+ input_filename=cv_filename,
115
+ jd_text=jd_text,
116
+ iterations_used=attempts_used,
117
+ all_filters_passed=bool(report and report.all_passed),
118
+ duration_seconds=duration,
119
+ )
120
+ _trace_line(trace_id, start, "render", f"Wrote PDF to {pdf_path}")
121
+
122
+ yield _trace(trace_id, start, StatusEvent(step="done", message=f"Done in {attempts_used} attempt(s); {duration:.1f}s"))
123
+ return PipelineResult(
124
+ success=True,
125
+ output_pdf=pdf_path,
126
+ iterations_used=attempts_used,
127
+ filter_report=report,
128
+ debug_dir=debug_dir,
129
+ )
130
+ except Exception as exc:
131
+ yield _trace(trace_id, start, StatusEvent(step="error", message=str(exc)))
132
+ return PipelineResult(success=False, error=str(exc), debug_dir=debug_dir)
133
+
134
+
135
+ def read_uploaded_bytes(value) -> bytes:
136
+ if isinstance(value, bytes):
137
+ return value
138
+ if isinstance(value, (str, Path)):
139
+ return Path(value).read_bytes()
140
+ if isinstance(value, dict):
141
+ for key in ("path", "name"):
142
+ if value.get(key):
143
+ return Path(value[key]).read_bytes()
144
+ raise ValueError("Could not read uploaded CV file.")
145
+
146
+
147
+ def _trace(trace_id: str, started_at: float, event: StatusEvent) -> StatusEvent:
148
+ _trace_line(trace_id, started_at, event.step, event.message, event.iteration)
149
+ return event
150
+
151
+
152
+ def _trace_filter_report(trace_id: str, started_at: float, report) -> None:
153
+ for result in report.results:
154
+ status = "PASS" if result.passed else "FAIL"
155
+ score = f"{result.score:.2f}" if isinstance(result.score, int | float) else str(result.score)
156
+ message = f"{result.filter_name}: {status} score={score}"
157
+ if result.feedback:
158
+ message += f" feedback={_compact(result.feedback)}"
159
+ warnings = result.detail.get("warnings") if isinstance(result.detail, dict) else None
160
+ if warnings:
161
+ message += f" warnings={_compact('; '.join(str(item) for item in warnings))}"
162
+ _trace_line(trace_id, started_at, "filter", message)
163
+
164
+
165
+ def _trace_line(
166
+ trace_id: str,
167
+ started_at: float,
168
+ step: str,
169
+ message: str,
170
+ iteration: int | None = None,
171
+ ) -> None:
172
+ elapsed = time.time() - started_at
173
+ iteration_part = f" iter={iteration + 1}" if iteration is not None else ""
174
+ print(f"[WORKFLOW {trace_id} +{elapsed:06.1f}s] [{step.upper()}]{iteration_part} {message}", flush=True)
175
+
176
+
177
+ def _compact(value: str, limit: int = 300) -> str:
178
+ text = " ".join(value.split())
179
+ if len(text) <= limit:
180
+ return text
181
+ return text[: limit - 3] + "..."
core/prompt_builder.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from models.cv import CVData
2
+
3
+
4
+ def build_extract_prompt(cv_text: str) -> list[dict]:
5
+ return [
6
+ {
7
+ "role": "system",
8
+ "content": (
9
+ "You are a CV parser. Extract structured data exactly as it appears. "
10
+ "Do not infer, add, or summarize. Return only JSON."
11
+ ),
12
+ },
13
+ {"role": "user", "content": cv_text},
14
+ ]
15
+
16
+
17
+ def build_optimize_prompt(
18
+ cv_data: CVData,
19
+ jd_text: str,
20
+ feedback: str,
21
+ tone: str,
22
+ language: str,
23
+ iteration: int,
24
+ ) -> list[dict]:
25
+ system = (
26
+ "You are a professional resume writer. Rules:\n"
27
+ "- Output a single self-contained HTML string in key 'html'\n"
28
+ "- One page only when rendered to PDF\n"
29
+ "- Inline CSS only, no images, ATS-safe fonts (Arial, Calibri, Georgia)\n"
30
+ "- Do not invent facts. Only use information from the provided CV JSON.\n"
31
+ f"- Tone: {tone}. Language: {language}.\n"
32
+ f"- This is iteration {iteration}."
33
+ )
34
+ user = cv_data.model_dump_json() + "\n\nJob Description:\n" + jd_text
35
+ if feedback:
36
+ user += f"\n\nPrevious attempt failed filters:\n{feedback}\nFix these issues."
37
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
38
+
core/renderer.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import platform
4
+ import re
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from models.config import AppSettings
9
+ from models.cv import CVData
10
+ from models.output import IndexRecord, OutputIndex
11
+ from models.resume import HTMLResume
12
+
13
+ TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
14
+
15
+
16
+ def render_pdf(
17
+ html_resume: HTMLResume,
18
+ cv_data: CVData,
19
+ settings: AppSettings,
20
+ input_filename: str = "",
21
+ jd_text: str = "",
22
+ iterations_used: int = 0,
23
+ all_filters_passed: bool = False,
24
+ duration_seconds: float = 0.0,
25
+ ) -> Path:
26
+ settings.output_dir.mkdir(parents=True, exist_ok=True)
27
+ timestamp = datetime.now().strftime("%m%d_%H%M")
28
+ name = _slugify(cv_data.name or "resume")
29
+ filename = f"{timestamp}_{name}_{settings.language}.pdf"
30
+ output_path = settings.output_dir / filename
31
+
32
+ try:
33
+ _ensure_homebrew_library_path()
34
+ from weasyprint import HTML
35
+ except OSError as exc:
36
+ raise RuntimeError(f"WeasyPrint system dependencies are missing: {exc}") from exc
37
+
38
+ html_document = _wrap_resume_html(html_resume.html)
39
+ HTML(string=html_document).write_pdf(output_path)
40
+
41
+ if settings.debug:
42
+ debug_dir = settings.output_dir / "debug"
43
+ debug_dir.mkdir(parents=True, exist_ok=True)
44
+ (debug_dir / f"iter_{html_resume.iteration}.html").write_text(html_document, encoding="utf-8")
45
+
46
+ record = IndexRecord(
47
+ input_filename=input_filename,
48
+ jd_snippet=jd_text[:300],
49
+ output_pdf=str(output_path),
50
+ iterations_used=iterations_used,
51
+ model=settings.model.name,
52
+ all_filters_passed=all_filters_passed,
53
+ duration_seconds=duration_seconds,
54
+ )
55
+ index_path = settings.output_dir / "index.json"
56
+ index = _load_index(index_path)
57
+ index.append_and_save(record, index_path)
58
+ return output_path
59
+
60
+
61
+ def _slugify(value: str) -> str:
62
+ return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") or "resume"
63
+
64
+
65
+ def _wrap_resume_html(html: str) -> str:
66
+ if re.search(r"<\s*html[\s>]", html, re.IGNORECASE):
67
+ return html
68
+ wrapper_path = TEMPLATE_DIR / "resume_wrapper.html"
69
+ if not wrapper_path.exists():
70
+ return f"<!doctype html><html><body>{html}</body></html>"
71
+ wrapper = wrapper_path.read_text(encoding="utf-8")
72
+ return wrapper.replace("{{HEADER}}", "").replace("{{BODY}}", html)
73
+
74
+
75
+ def _ensure_homebrew_library_path() -> None:
76
+ if platform.system() != "Darwin":
77
+ return
78
+ homebrew_lib = "/opt/homebrew/lib"
79
+ if not Path(homebrew_lib).exists():
80
+ homebrew_lib = "/usr/local/lib"
81
+ existing = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "")
82
+ paths = [path for path in existing.split(":") if path]
83
+ if homebrew_lib not in paths:
84
+ paths.insert(0, homebrew_lib)
85
+ os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(paths)
86
+
87
+
88
+ def _load_index(path: Path) -> OutputIndex:
89
+ if not path.exists() or not path.read_text(encoding="utf-8").strip():
90
+ return OutputIndex()
91
+ return OutputIndex.model_validate(json.loads(path.read_text(encoding="utf-8")))
filters/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Validation filters for generated resumes."""
2
+