Spaces:
Runtime error
Runtime error
Upload 105 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +29 -0
- README.md +64 -10
- agents/__init__.py +2 -0
- agents/__pycache__/__init__.cpython-311.pyc +0 -0
- agents/__pycache__/__init__.cpython-312.pyc +0 -0
- agents/__pycache__/ai_generated_detector.cpython-311.pyc +0 -0
- agents/__pycache__/ai_generated_detector.cpython-312.pyc +0 -0
- agents/__pycache__/combined_reviewer.cpython-311.pyc +0 -0
- agents/__pycache__/combined_reviewer.cpython-312.pyc +0 -0
- agents/__pycache__/extractor.cpython-311.pyc +0 -0
- agents/__pycache__/extractor.cpython-312.pyc +0 -0
- agents/__pycache__/hallucination_detector.cpython-311.pyc +0 -0
- agents/__pycache__/hallucination_detector.cpython-312.pyc +0 -0
- agents/__pycache__/job_parser.cpython-311.pyc +0 -0
- agents/__pycache__/job_parser.cpython-312.pyc +0 -0
- agents/__pycache__/modal_model.cpython-311.pyc +0 -0
- agents/__pycache__/modal_model.cpython-312.pyc +0 -0
- agents/__pycache__/name_extractor.cpython-311.pyc +0 -0
- agents/__pycache__/name_extractor.cpython-312.pyc +0 -0
- agents/__pycache__/optimizer.cpython-311.pyc +0 -0
- agents/__pycache__/optimizer.cpython-312.pyc +0 -0
- agents/ai_generated_detector.py +60 -0
- agents/combined_reviewer.py +111 -0
- agents/extractor.py +269 -0
- agents/hallucination_detector.py +101 -0
- agents/job_parser.py +34 -0
- agents/modal_model.py +45 -0
- agents/name_extractor.py +36 -0
- agents/optimizer.py +226 -0
- app.py +12 -0
- config.py +9 -0
- core/__init__.py +2 -0
- core/__pycache__/__init__.cpython-311.pyc +0 -0
- core/__pycache__/__init__.cpython-312.pyc +0 -0
- core/__pycache__/llm_client.cpython-311.pyc +0 -0
- core/__pycache__/llm_client.cpython-312.pyc +0 -0
- core/__pycache__/pdf_reader.cpython-311.pyc +0 -0
- core/__pycache__/pdf_reader.cpython-312.pyc +0 -0
- core/__pycache__/pipeline.cpython-311.pyc +0 -0
- core/__pycache__/pipeline.cpython-312.pyc +0 -0
- core/__pycache__/prompt_builder.cpython-311.pyc +0 -0
- core/__pycache__/prompt_builder.cpython-312.pyc +0 -0
- core/__pycache__/renderer.cpython-311.pyc +0 -0
- core/__pycache__/renderer.cpython-312.pyc +0 -0
- core/llm_client.py +74 -0
- core/pdf_reader.py +27 -0
- core/pipeline.py +181 -0
- core/prompt_builder.py +38 -0
- core/renderer.py +91 -0
- 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:
|
| 3 |
-
|
| 4 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|