Resume Optimizer
The Resume Optimizer is a fully browser-based AI agent that rewrites your resume to beat Applicant Tracking Systems (ATS) and land more interviews. Upload your existing resume as a PDF, DOCX, or plain text file — the agent extracts the text using pypdf and python-docx running via Pyodide (Python compiled to WebAssembly) — then paste the job description you are targeting. The agent analyzes both documents locally and delivers four results simultaneously. First, an ATS compatibility score (0–100) with a breakdown across keyword coverage, formatting compliance, action verb usage, and achievement quantification. Second, a keyword analysis panel showing every required skill and term from the job description as either matched (present in your resume) or missing (absent but needed). Third, a set of issues and recommendations ranked as critical, warning, or informational — covering specific bullet points to rewrite, missing sections, and suggested power verbs for your industry and role level. Fourth, a full optimized resume with rewritten bullet points, added keywords, and ATS-safe formatting applied. The three-panel interface lets you view the original, the optimized version, and a direct side-by-side comparison. When you are satisfied, export the optimized resume as a PDF (via jsPDF), DOCX, or plain text with one click. The template supports seven target industries — Technology, Finance, Healthcare, Marketing, Engineering, Consulting, and Other — and four role levels: Entry, Mid, Senior, and Executive. Because the entire pipeline — PDF text extraction, keyword matching, ATS scoring, and LLM rewriting — runs inside the browser, no resume data is ever uploaded to an external server. Compatible with GPT-4o, Claude 3.5 Sonnet, or a fully local WebLLM model for complete offline privacy.
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
About This Template
Resume Optimizer is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.
Template Metadata
- Slug
- resume-optimizer
- Created By
- ozzo
- Created
- Feb 28, 2026
- Usage Count
- 1
Tags
Code Statistics
- HTML Lines
- 278
- CSS Lines
- 714
- JS Lines
- 997
- Python Lines
- 259
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{ agent_name }} - Resume Optimizer</title>
<style>
{{ css_code }}
</style>
<!-- Conditional Script Imports -->
{% if needs_pyodide %}
<script src="https://cdn.jsdelivr.net/pyodide/v{{ pyodide_version }}/full/pyodide.js"></script>
{% endif %}
<!-- jsPDF for PDF export -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script>
const PROVIDER = "{{ embedded_provider }}";
const API_KEY = "{{ embedded_api_key }}";
const AGENT_CONFIG = {{ default_config|safe }};
const NEEDS_PYODIDE = {{ needs_pyodide|lower }};
const PYODIDE_VERSION = "{{ pyodide_version }}";
</script>
</head>
<body>
<div class="resume-optimizer-dashboard">
<!-- Left Panel: Input & Settings -->
<div class="left-panel">
<div class="panel-header">
<div class="icon-badge">📄</div>
<div>
<h2>Resume Optimizer</h2>
<p class="tagline">Transform your resume into an ATS-winning document</p>
</div>
</div>
<!-- Resume Upload Section -->
<div class="section">
<h3>📄 Upload Your Resume</h3>
<div class="upload-area" id="resume-upload-area">
<div class="upload-content">
<div class="upload-icon">📤</div>
<p class="upload-text">Drag & drop your resume<br>or click to browse</p>
<p class="upload-hint">Supported: PDF, DOCX, TXT • Max 10 MB</p>
</div>
<input type="file" id="resume-file-input" accept=".pdf,.docx,.txt" hidden>
</div>
<button class="secondary-btn" id="paste-resume-btn" onclick="toggleResumeTextarea()">
📋 Or Paste Resume Text
</button>
<textarea id="resume-text-input"
class="text-input hidden"
placeholder="Paste your resume text here..."
rows="6"></textarea>
</div>
<!-- Job Description Section -->
<div class="section">
<h3>🎯 Target Job Description</h3>
<textarea id="job-description-input"
class="text-input"
placeholder="Paste the job posting you're applying for..."
rows="8"></textarea>
<div class="char-count" id="jd-char-count">0 / 5000 characters</div>
<div class="button-group">
<button class="secondary-btn" onclick="loadSampleJob()">📂 Load Sample</button>
</div>
</div>
<!-- Settings Section -->
<div class="section">
<h3>⚙️ Optimization Settings</h3>
<label class="form-label">Target Industry</label>
<select id="industry-select" class="select-input">
<option value="technology">Technology</option>
<option value="finance">Finance</option>
<option value="healthcare">Healthcare</option>
<option value="marketing">Marketing & Sales</option>
<option value="engineering">Engineering</option>
<option value="consulting">Consulting</option>
<option value="other">Other</option>
</select>
<label class="form-label">Target Role Level</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="level" value="entry" checked>
<span>Entry-level</span>
</label>
<label class="radio-label">
<input type="radio" name="level" value="mid">
<span>Mid-level</span>
</label>
<label class="radio-label">
<input type="radio" name="level" value="senior">
<span>Senior</span>
</label>
<label class="radio-label">
<input type="radio" name="level" value="executive">
<span>Executive</span>
</label>
</div>
<label class="form-label">Optimization Options</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Extract keywords from job</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Optimize bullet points</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Fix ATS formatting</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Add missing keywords</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Highlight achievements</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked>
<span>Suggest action verbs</span>
</label>
</div>
<button class="primary-btn" id="optimize-btn" onclick="optimizeResume()">
<span class="btn-icon">🚀</span>
<span>Optimize Resume</span>
</button>
</div>
</div>
<!-- Center Panel: Preview & Editor -->
<div class="center-panel">
<div class="panel-header">
<h2>Resume Preview</h2>
<div class="view-toggle">
<button class="toggle-btn active" data-view="original" onclick="switchView('original')">Original</button>
<button class="toggle-btn" data-view="optimized" onclick="switchView('optimized')">Optimized</button>
<button class="toggle-btn" data-view="comparison" onclick="switchView('comparison')">Compare</button>
</div>
</div>
<div class="preview-container" id="preview-container">
<div class="welcome-state" id="welcome-state">
<div class="welcome-icon">📄</div>
<h3>Ready to Optimize</h3>
<p>Upload your resume and paste a job description to get started</p>
<div class="feature-list">
<div class="feature-item">✨ AI-Powered Analysis</div>
<div class="feature-item">📊 ATS Compatibility Score</div>
<div class="feature-item">🎯 Keyword Optimization</div>
<div class="feature-item">💡 Smart Recommendations</div>
</div>
</div>
<!-- Original View -->
<div class="resume-view hidden" id="original-view">
<div class="resume-document">
<div id="original-content"></div>
</div>
</div>
<!-- Optimized View -->
<div class="resume-view hidden" id="optimized-view">
<div class="resume-document">
<div id="optimized-content"></div>
</div>
</div>
<!-- Comparison View -->
<div class="comparison-view hidden" id="comparison-view">
<div class="comparison-split">
<div class="comparison-side">
<div class="comparison-label">Original</div>
<div class="resume-document">
<div id="comparison-original"></div>
</div>
</div>
<div class="comparison-divider"></div>
<div class="comparison-side">
<div class="comparison-label">Optimized</div>
<div class="resume-document">
<div id="comparison-optimized"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Export Section -->
<div class="export-section hidden" id="export-section">
<h3>📤 Export & Download</h3>
<div class="button-group">
<button class="primary-btn" onclick="downloadResume('pdf')">
📄 Download as PDF
</button>
<button class="primary-btn" onclick="downloadResume('docx')">
📋 Download as DOCX
</button>
<button class="secondary-btn" onclick="downloadResume('txt')">
📝 Download as TXT
</button>
<button class="secondary-btn" onclick="copyToClipboard()">
📋 Copy to Clipboard
</button>
</div>
</div>
</div>
<!-- Right Panel: Analysis -->
<div class="right-panel">
<!-- ATS Score -->
<div class="analysis-card">
<h3>🎯 ATS Compatibility Score</h3>
<div id="ats-score-display" class="score-display">
<div class="score-placeholder">
<div class="placeholder-icon">📊</div>
<p>Upload resume to see score</p>
</div>
</div>
</div>
<!-- Keyword Analysis -->
<div class="analysis-card">
<h3>🔑 Keyword Analysis</h3>
<div id="keyword-analysis-display" class="keyword-display">
<div class="score-placeholder">
<div class="placeholder-icon">🔍</div>
<p>Analyzing keywords...</p>
</div>
</div>
</div>
<!-- Issues & Recommendations -->
<div class="analysis-card">
<h3>⚠️ Issues & Recommendations</h3>
<div id="recommendations-display" class="recommendations-display">
<div class="score-placeholder">
<div class="placeholder-icon">💡</div>
<p>Recommendations will appear here</p>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div class="loading-overlay hidden" id="loading-overlay">
<div class="loading-spinner"></div>
<p class="loading-text">Analyzing your resume...</p>
</div>
</div>
<script>
{{ js_code }}
</script>
<!-- Hidden Python code -->
<script type="text/python" id="python-code">
{{ python_code }}
</script>
</body>
</html>
:root {
--primary-blue: #1E40AF;
--accent-green: #10B981;
--bg-gray: #F3F4F6;
--text-dark: #1F2937;
--text-muted: #6B7280;
--border: #E5E7EB;
--white: #FFFFFF;
--red: #EF4444;
--yellow: #FBBF24;
--success: #10B981;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', 'Poppins', sans-serif;
background: linear-gradient(135deg, #EFF6FF 0%, #F9FAFB 100%);
color: var(--text-dark);
line-height: 1.6;
}
.resume-optimizer-dashboard {
max-width: 1800px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
grid-template-columns: 380px 1fr 380px;
gap: 1.5rem;
min-height: 100vh;
}
/* Panel Styles */
.left-panel, .center-panel, .right-panel {
background: var(--white);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--border);
height: fit-content;
}
.center-panel {
min-height: 600px;
}
.right-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Panel Headers */
.panel-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border);
}
.icon-badge {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--primary-blue), #3B82F6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.panel-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--text-dark);
font-weight: 700;
}
.tagline {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
}
/* Sections */
.section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.section:last-child {
border-bottom: none;
}
.section h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-dark);
}
/* Upload Area */
.upload-area {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 1rem;
background: var(--bg-gray);
}
.upload-area:hover, .upload-area.drag-over {
border-color: var(--primary-blue);
background: #EFF6FF;
}
.upload-area.has-file {
border-color: var(--accent-green);
background: #F0FDF4;
}
.upload-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.upload-text {
margin: 0.5rem 0;
font-weight: 500;
color: var(--text-dark);
}
.upload-hint {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Form Elements */
.text-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
transition: all 0.2s;
}
.text-input:focus {
outline: none;
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
.select-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 0.875rem;
background: var(--white);
cursor: pointer;
transition: all 0.2s;
margin-bottom: 1rem;
}
.select-input:focus {
outline: none;
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-dark);
}
.char-count {
font-size: 0.75rem;
color: var(--text-muted);
text-align: right;
margin-top: 0.25rem;
}
/* Radio & Checkbox Groups */
.radio-group, .checkbox-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.radio-label, .checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
}
.radio-label input, .checkbox-label input {
cursor: pointer;
}
/* Buttons */
.primary-btn, .secondary-btn {
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
}
.primary-btn {
background: var(--primary-blue);
color: var(--white);
}
.primary-btn:hover:not(:disabled) {
background: #1E3A8A;
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.secondary-btn {
background: var(--bg-gray);
color: var(--text-dark);
border: 1px solid var(--border);
}
.secondary-btn:hover:not(:disabled) {
background: #E5E7EB;
}
.primary-btn:disabled, .secondary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon {
font-size: 1.125rem;
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.button-group button {
flex: 1;
}
/* View Toggle */
.view-toggle {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--white);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.toggle-btn:hover {
background: var(--bg-gray);
}
.toggle-btn.active {
background: var(--primary-blue);
color: var(--white);
border-color: var(--primary-blue);
}
/* Preview Container */
.preview-container {
min-height: 500px;
background: var(--bg-gray);
border-radius: 8px;
padding: 1.5rem;
position: relative;
}
/* Welcome State */
.welcome-state {
text-align: center;
padding: 3rem 1rem;
}
.welcome-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.welcome-state h3 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
color: var(--text-dark);
}
.welcome-state p {
color: var(--text-muted);
margin: 0 0 2rem 0;
}
.feature-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
max-width: 400px;
margin: 0 auto;
}
.feature-item {
background: var(--white);
padding: 0.75rem;
border-radius: 8px;
font-size: 0.875rem;
box-shadow: var(--shadow);
}
/* Resume Views */
.resume-view, .comparison-view {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.resume-document {
background: var(--white);
border-radius: 8px;
padding: 2rem;
box-shadow: var(--shadow);
min-height: 400px;
font-size: 0.875rem;
line-height: 1.6;
}
.resume-document iframe {
width: 100%;
height: 900px;
border: 0;
border-radius: 8px;
background: var(--white);
}
.resume-pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-dark);
}
/* Comparison View */
.comparison-split {
display: flex;
flex-direction: column;
gap: 2rem;
}
.comparison-divider {
height: 2px;
background: linear-gradient(to right, transparent, var(--border), transparent);
margin: 1rem 0;
border-radius: 1px;
}
.comparison-label {
font-weight: 700;
margin-bottom: 1rem;
color: var(--primary-blue);
font-size: 1.1rem;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, #EFF6FF, #F0F9FF);
border-left: 4px solid var(--primary-blue);
border-radius: 4px;
}
/* Export Section */
.export-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid var(--border);
}
.export-section h3 {
font-size: 1rem;
margin: 0 0 1rem 0;
}
/* Analysis Cards */
.analysis-card {
background: var(--white);
border-radius: 12px;
padding: 1.25rem;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.analysis-card h3 {
margin: 0 0 1rem 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-dark);
}
/* Score Display */
.score-display, .keyword-display, .recommendations-display {
min-height: 150px;
}
.score-placeholder {
text-align: center;
padding: 2rem 1rem;
}
.placeholder-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.score-placeholder p {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
}
/* Score Visualization */
.score-comparison {
display: flex;
justify-content: space-around;
align-items: center;
margin: 1.5rem 0;
}
.score-item {
text-align: center;
}
.score-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.score-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-blue);
}
.score-arrow {
font-size: 2rem;
color: var(--accent-green);
}
.score-breakdown {
background: var(--bg-gray);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.score-breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.score-breakdown-item:last-child {
margin-bottom: 0;
}
.score-bar {
flex: 1;
height: 8px;
background: #E5E7EB;
border-radius: 4px;
margin: 0 0.75rem;
overflow: hidden;
}
.score-bar-fill {
height: 100%;
background: var(--accent-green);
border-radius: 4px;
transition: width 0.5s ease;
}
.score-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-green {
background: #D1FAE5;
color: #065F46;
}
.badge-yellow {
background: #FEF3C7;
color: #92400E;
}
.badge-red {
background: #FEE2E2;
color: #991B1B;
}
/* Keyword Analysis */
.keyword-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.keyword-tag {
padding: 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.keyword-tag.matched {
background: #D1FAE5;
color: #065F46;
}
.keyword-tag.missing {
background: #FEE2E2;
color: #991B1B;
}
.keyword-density {
background: var(--bg-gray);
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
font-size: 0.875rem;
}
/* Recommendations */
.recommendation-item {
background: var(--bg-gray);
border-left: 3px solid var(--border);
padding: 1rem;
border-radius: 6px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.recommendation-item:last-child {
margin-bottom: 0;
}
.recommendation-item.critical {
border-left-color: var(--red);
background: #FEF2F2;
}
.recommendation-item.warning {
border-left-color: var(--yellow);
background: #FFFBEB;
}
.recommendation-item.info {
border-left-color: var(--primary-blue);
background: #EFF6FF;
}
.recommendation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.recommendation-text {
color: var(--text-dark);
line-height: 1.5;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid var(--white);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: var(--white);
margin-top: 1rem;
font-size: 1rem;
font-weight: 500;
}
/* Utility Classes */
.hidden {
display: none !important;
}
/* Responsive Design */
@media (max-width: 1400px) {
.resume-optimizer-dashboard {
grid-template-columns: 350px 1fr 350px;
gap: 1rem;
}
}
@media (max-width: 1200px) {
.resume-optimizer-dashboard {
grid-template-columns: 1fr;
}
.right-panel {
order: -1;
}
.comparison-split {
grid-template-columns: 1fr;
}
.comparison-divider {
height: 2px;
width: 100%;
}
}
@media (max-width: 768px) {
.resume-optimizer-dashboard {
padding: 1rem;
}
.feature-list {
grid-template-columns: 1fr;
}
.keyword-grid {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.view-toggle {
flex-wrap: wrap;
}
}
<script>
// ========================
// Global state
// ========================
let resumeText = '';
let jobDescription = '';
let optimizedResume = '';
let analysisData = null;
// Uploaded file state for proper PDF preview
let uploadedResumeFileType = null;
let uploadedResumePdfUrl = null;
let uploadedResumePdfDataUrl = null;
// ========================
// Text normalization helpers
// ========================
function stripControlCharsKeepNewlines(text) {
if (!text) return '';
return String(text).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
function fixCommonSpacing(text) {
if (!text) return '';
let t = String(text);
// Sentence ends: "word.word" -> "word. Word"
t = t.replace(/([a-z])\.([A-Z])/g, '$1. $2');
// Commas/semicolons: "word,word" -> "word, word"
t = t.replace(/([a-zA-Z]),([a-zA-Z])/g, '$1, $2');
// Colons: "Word:Word" -> "Word: Word"
t = t.replace(/([a-zA-Z]):([a-zA-Z])/g, '$1: $2');
// Dashes: "likeGDB" -> "like GDB"
t = t.replace(/([a-zA-Z])(\d)/g, '$1 $2');
// Normalize NBSP and collapse spaces
t = t.replace(/\u00A0/g, ' ').replace(/ {2,}/g, ' ');
return t;
}
function collapseSpacedLettersInLine(line) {
if (!line) return '';
const parts = line.split(' ').filter(Boolean);
if (parts.length < 8) return line;
const singleCharParts = parts.filter(p => p.length === 1);
const ratio = singleCharParts.length / parts.length;
if (ratio < 0.75) return line;
let joined = '';
let buffer = '';
for (const p of parts) {
if (p.length === 1) {
buffer += p;
} else {
if (buffer) {
joined += buffer + ' ';
buffer = '';
}
joined += p + ' ';
}
}
if (buffer) joined += buffer;
joined = joined.trim();
// "July2022" -> "July 2022"
joined = joined.replace(
/(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)(\d{4})/gi,
'$1 $2'
);
// "2020February" -> "2020 February"
joined = joined.replace(/(\d{4})([A-Za-z]+)/g, '$1 $2');
return joined;
}
function normalizeExtractedResumeText(rawText) {
if (!rawText) return '';
let text = String(rawText);
// Strip control characters (keep newlines)
text = stripControlCharsKeepNewlines(text);
// Common PDF bullet chars
text = text
.replace(/•/g, '• ')
.replace(/●/g, '● ')
.replace(/\u00b7/g, '• ')
.replace(/\u2022/g, '• ');
// Fix some specific spacing / artifacts if needed later
text = text.replace(/githublinkedin\.com/gi, 'github linkedin.com');
// Collapse spaced letters line by line
const lines = text.split('\n').map((l) => collapseSpacedLettersInLine(l));
text = lines.join('\n');
text = fixCommonSpacing(text);
return text.trim();
}
function normalizeOptimizedResumeText(rawText) {
if (!rawText) return '';
let text = String(rawText);
text = stripControlCharsKeepNewlines(text);
text = fixCommonSpacing(text);
return text.trim();
}
// ========================
// File upload handlers
// ========================
function setupFileUpload() {
const uploadArea = document.getElementById('resume-upload-area');
const fileInput = document.getElementById('resume-file-input');
if (!uploadArea || !fileInput) return;
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('drag-over');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('drag-over');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
}
async function handleFileUpload(file) {
const uploadArea = document.getElementById('resume-upload-area');
if (!uploadArea) return;
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
alert('File size exceeds 10 MB limit');
return;
}
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'text/plain',
];
if (!allowedTypes.includes(file.type)) {
alert('Unsupported file type. Please upload PDF, DOCX, DOC, or TXT.');
return;
}
uploadArea.innerHTML = `
<div class="upload-content">
<div class="upload-icon">⏳</div>
<p class="upload-text">Processing ${file.name}...</p>
<p class="upload-hint">Please wait while we extract the text</p>
</div>
`;
try {
// Clear previous PDF preview URL
if (uploadedResumePdfUrl) {
try { URL.revokeObjectURL(uploadedResumePdfUrl); } catch (e) {}
uploadedResumePdfUrl = null;
}
uploadedResumeFileType = file.type;
uploadedResumePdfDataUrl = null;
if (file.type === 'application/pdf') {
const isFileOrigin = window.location.protocol === 'file:';
if (isFileOrigin) {
// Use DataURL for local file
uploadedResumePdfUrl = null;
uploadedResumePdfDataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(new Error('Failed to read PDF for preview'));
r.readAsDataURL(file);
});
} else {
uploadedResumePdfUrl = URL.createObjectURL(file);
}
}
const text = await extractTextFromFile(file);
resumeText = normalizeExtractedResumeText(text);
uploadArea.classList.add('has-file');
uploadArea.innerHTML = `
<div class="upload-content">
<div class="upload-icon">✅</div>
<p class="upload-text">${file.name}</p>
<p class="upload-hint">${formatFileSize(file.size)} • Click to change</p>
</div>
`;
updateOriginalView(resumeText);
} catch (error) {
console.error('File upload error:', error);
uploadArea.innerHTML = `
<div class="upload-content">
<div class="upload-icon">⚠️</div>
<p class="upload-text">Error processing file</p>
<p class="upload-hint">${error.message}</p>
</div>
`;
}
}
async function extractTextFromFile(file) {
if (file.type === 'text/plain') {
return await file.text();
}
// DOCX/DOC via python-docx
if (
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.type === 'application/msword'
) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const arrayBuffer = reader.result;
const bytes = new Uint8Array(arrayBuffer);
if (!window.pyodide) {
reject(new Error('Pyodide not initialized yet. Please wait and try again.'));
return;
}
try {
await window.pyodide.loadPackage(['micropip']);
await window.pyodide.runPythonAsync(`
import micropip
try:
await micropip.install('python-docx')
except Exception as e:
print('python-docx install error or already installed:', e)
`);
} catch (e) {
console.log('python-docx already installed or error:', e);
}
window.pyodide.globals.set('docx_bytes', bytes);
const extractedText = await window.pyodide.runPythonAsync(`
import io
from docx import Document
docx_file = io.BytesIO(bytes(docx_bytes))
doc = Document(docx_file)
text_content = [p.text for p in doc.paragraphs if p.text.strip()]
"\\n".join(text_content)
`);
if (!extractedText || !String(extractedText).trim()) {
reject(new Error('Could not extract text from document. The file may be empty or corrupted.'));
return;
}
resolve(extractedText);
} catch (err) {
console.error('DOCX extraction error:', err);
reject(new Error('Failed to extract document text: ' + err.message));
}
};
reader.onerror = () => reject(new Error('Failed to read document for extraction'));
reader.readAsArrayBuffer(file);
});
}
// PDF via pypdf
if (file.type === 'application/pdf') {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const arrayBuffer = reader.result;
const bytes = new Uint8Array(arrayBuffer);
if (!window.pyodide) {
reject(new Error('Pyodide not initialized yet. Please wait and try again.'));
return;
}
try {
await window.pyodide.loadPackage(['micropip']);
await window.pyodide.runPythonAsync(`
import micropip
try:
await micropip.install('pypdf>=4.0.0')
except Exception as e:
print('pypdf install error or already installed:', e)
`);
} catch (e) {
console.log('pypdf already installed or error:', e);
}
window.pyodide.globals.set('pdf_bytes', bytes);
const extractedText = await window.pyodide.runPythonAsync(`
import io
from pypdf import PdfReader
pdf_file = io.BytesIO(bytes(pdf_bytes))
reader = PdfReader(pdf_file)
text_content = []
for page in reader.pages:
try:
page_text = page.extract_text(extraction_mode="layout")
except TypeError:
page_text = page.extract_text()
except Exception:
page_text = page.extract_text()
if page_text:
text_content.append(page_text)
"\\n".join(text_content)
`);
if (!extractedText || !String(extractedText).trim()) {
reject(new Error('Could not extract text from PDF. The file may be scanned or image-based.'));
return;
}
resolve(extractedText);
} catch (err) {
console.error('PDF extraction error:', err);
reject(new Error('Failed to extract PDF text: ' + err.message));
}
};
reader.onerror = () => reject(new Error('Failed to read PDF for extraction'));
reader.readAsArrayBuffer(file);
});
}
throw new Error('Unsupported file type');
}
// ========================
// Misc helpers
// ========================
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
// ========================
// Text inputs & view switching
// ========================
function toggleResumeTextarea() {
const textarea = document.getElementById('resume-text-input');
const btn = document.getElementById('paste-resume-btn');
if (!textarea || !btn) return;
if (textarea.classList.contains('hidden')) {
textarea.classList.remove('hidden');
btn.textContent = 'Use File Upload Instead';
} else {
textarea.classList.add('hidden');
btn.textContent = '📋 Or Paste Resume Text';
}
}
function setupTextInputs() {
const resumeTextarea = document.getElementById('resume-text-input');
const jobDescTextarea = document.getElementById('job-description-input');
const charCount = document.getElementById('jd-char-count');
if (resumeTextarea) {
resumeTextarea.addEventListener('input', (e) => {
resumeText = e.target.value;
updateOriginalView(resumeText);
});
}
if (jobDescTextarea && charCount) {
jobDescTextarea.addEventListener('input', (e) => {
jobDescription = e.target.value;
const count = jobDescription.length;
charCount.textContent = `${count} / 5000 characters`;
charCount.style.color = count > 5000 ? 'var(--red)' : 'var(--text-muted)';
});
}
}
function switchView(viewName) {
document.querySelectorAll('.toggle-btn').forEach((btn) => {
btn.classList.remove('active');
if (btn.dataset.view === viewName) {
btn.classList.add('active');
}
});
const views = ['welcome-state', 'original-view', 'optimized-view', 'comparison-view'];
views.forEach((v) => {
const el = document.getElementById(v);
if (!el) return;
el.classList.add('hidden');
});
if (viewName === 'original') {
document.getElementById('original-view')?.classList.remove('hidden');
} else if (viewName === 'optimized') {
document.getElementById('optimized-view')?.classList.remove('hidden');
} else if (viewName === 'comparison') {
document.getElementById('comparison-view')?.classList.remove('hidden');
}
}
function updateOriginalView(text) {
const welcomeState = document.getElementById('welcome-state');
const originalView = document.getElementById('original-view');
const originalContent = document.getElementById('original-content');
const comparisonOriginal = document.getElementById('comparison-original');
if (!originalContent || !comparisonOriginal) return;
if (text && text.trim()) {
welcomeState?.classList.add('hidden');
originalView?.classList.remove('hidden');
const pdfSrc = uploadedResumePdfDataUrl || uploadedResumePdfUrl;
if (uploadedResumeFileType === 'application/pdf' && pdfSrc) {
const pdfIframe = `<iframe src="${pdfSrc}" title="Resume PDF Preview"></iframe>`;
originalContent.innerHTML = pdfIframe;
comparisonOriginal.innerHTML = pdfIframe;
} else {
const formatted = formatResumeText(text);
originalContent.innerHTML = formatted;
comparisonOriginal.innerHTML = formatted;
}
switchView('original');
}
}
function formatResumeText(text) {
return `<pre class="resume-pre">${escapeHtml(text)}</pre>`;
}
// ========================
// Optimization (existing code, unchanged)
// ========================
async function optimizeResume() {
if (!resumeText || !resumeText.trim()) {
alert('Please upload or paste your resume first');
return;
}
if (!jobDescription.trim()) {
alert('Please paste the job description');
return;
}
// For local provider, optimization is driven by the in-browser WebLLM agent.
// The model MUST be loaded first via the model selector.
if (!window.agentManager || !window.agentManager.isLoaded || !window.agentManager.wllama) {
alert('Please load an AI model first (top bar) before optimizing.');
return;
}
const optimizeBtn = document.getElementById('optimize-btn');
const loadingOverlay = document.getElementById('loading-overlay');
optimizeBtn.disabled = true;
optimizeBtn.innerHTML = '<span class="btn-icon">⏳</span><span>Optimizing...</span>';
loadingOverlay.classList.remove('hidden');
try {
// Get settings
const industry = document.getElementById('industry-select').value;
const level = document.querySelector('input[name="level"]:checked').value;
// Use cleaned text for best results (PDF extraction can introduce artifacts)
const resumeForOptimization = normalizeExtractedResumeText(resumeText);
// Route through the unified agent pipeline so WebLLM decides tool calls.
// We instruct the model to call optimize_resume (Python tool) and then
// synthesize a STRICT JSON response for the UI.
const query =
`You are a resume optimization assistant.\n\n` +
`You have access to Python tools. You MUST call the tool optimize_resume exactly once to compute ATS/keyword analysis and recommendations.\n` +
`After the tool returns, you MUST respond with ONLY a valid JSON object (no markdown, no backticks, no extra text).\n\n` +
`JSON schema required:\n` +
`{\n` +
` "optimized_resume": string,\n` +
` "ats_score": {"before": number, "after": number, "breakdown": object},\n` +
` "keyword_analysis": {"matched": array, "missing": array, "total": number, "density": number},\n` +
` "recommendations": [{"type": "critical"|"warning"|"info", "title": string, "text": string}]\n` +
`}\n\n` +
`Input parameters for optimize_resume:\n` +
`- resume_text: ${JSON.stringify(resumeForOptimization)}\n` +
`- job_description: ${JSON.stringify(jobDescription)}\n` +
`- industry: ${JSON.stringify(industry)}\n` +
`- experience_level: ${JSON.stringify(level)}\n\n` +
`Optimization requirements for optimized_resume:\n` +
`- Keep the original structure and headings\n` +
`- Strengthen action verbs and bullet points\n` +
`- Incorporate missing keywords naturally (do not keyword-stuff)\n` +
`- Keep it ATS-friendly and professional\n` +
`- Remove duplicated lines/headings (e.g., repeated name/contact)\n` +
`- Fix spacing/line breaks; do not leave dangling words on their own line\n` +
`- Use consistent bullet formatting and avoid incomplete bullets\n` +
`- Replace placeholders/typos (e.g., 'Matched') with correct, meaningful wording\n`;
const parseMaybeJson = (text) => {
try {
return JSON.parse(text);
} catch (_) {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
const slice = text.slice(start, end + 1);
return JSON.parse(slice);
}
throw new Error('Model did not return valid JSON');
}
};
// Call the unified Python entrypoint (async) via Pyodide
window.pyodide.globals.set('user_query', query);
const resultText = await window.pyodide.runPythonAsync(
`await process_user_query(user_query)`
);
// Parse model result (must be JSON with the schema above)
const data = parseMaybeJson(resultText);
if (data && typeof data.optimized_resume === 'string') {
data.optimized_resume = normalizeOptimizedResumeText(data.optimized_resume);
}
optimizedResume = data.optimized_resume;
analysisData = data;
// Update views
updateOptimizedView(data);
updateAnalysisPanel(data);
// Show export section
document.getElementById('export-section').classList.remove('hidden');
// Switch to comparison view
switchView('comparison');
} catch (error) {
console.error('Optimization error:', error);
alert('Error optimizing resume: ' + error.message);
} finally {
optimizeBtn.disabled = false;
optimizeBtn.innerHTML = '<span class="btn-icon">🚀</span><span>Optimize Resume</span>';
loadingOverlay.classList.add('hidden');
}
}
// ========================
// Analysis panel updates
// ========================
function updateOptimizedView(data) {
const optimizedContent = document.getElementById('optimized-content');
const comparisonOptimized = document.getElementById('comparison-optimized');
if (!optimizedContent || !comparisonOptimized || !data || !data.optimized_resume) return;
const formatted = formatResumeText(normalizeOptimizedResumeText(data.optimized_resume));
optimizedContent.innerHTML = formatted;
comparisonOptimized.innerHTML = formatted;
}
function updateAnalysisPanel(data) {
if (!data) return;
updateATSScore(data.ats_score || {});
updateKeywordAnalysis(data.keyword_analysis || {});
updateRecommendations(data.recommendations || []);
}
function updateATSScore(scoreData) {
const scoreDisplay = document.getElementById('ats-score-display');
if (!scoreDisplay || !scoreData) return;
const before = scoreData.before ?? 0;
const after = scoreData.after ?? 0;
const breakdown = scoreData.breakdown || {};
scoreDisplay.innerHTML = `
<div class="score-comparison">
<div class="score-item">
<div class="score-label">Before</div>
<div class="score-value" style="color: var(--yellow);">${before}</div>
</div>
<div class="score-arrow">➡️</div>
<div class="score-item">
<div class="score-label">After</div>
<div class="score-value" style="color: var(--accent-green);">${after}</div>
</div>
</div>
<div class="score-breakdown">
<h4 style="margin: 0 0 0.75rem 0; font-size: 0.875rem;">Score Breakdown</h4>
${Object.entries(breakdown)
.map(([key, value]) => {
const val = Number(value) || 0;
const badgeClass =
val >= 80 ? 'badge-green' : val >= 60 ? 'badge-yellow' : 'badge-red';
return `
<div class="score-breakdown-item">
<span>${escapeHtml(key)}</span>
<div class="score-bar">
<div class="score-bar-fill" style="width: ${val}%;"></div>
</div>
<span class="score-badge ${badgeClass}">${val}</span>
</div>
`;
})
.join('')}
</div>
`;
}
function updateKeywordAnalysis(keywordData) {
const keywordDisplay = document.getElementById('keyword-analysis-display');
if (!keywordDisplay || !keywordData) return;
const matched = keywordData.matched || [];
const missing = keywordData.missing || [];
const total = keywordData.total ?? matched.length + missing.length;
const density = keywordData.density ?? 0;
const densityLabel =
density >= 2 && density <= 3 ? 'Optimal (2–3%)' : 'Needs adjustment';
keywordDisplay.innerHTML = `
<p style="margin: 0 0 1rem 0; font-size: 0.875rem;">
<strong>Matched Keywords</strong>: ${matched.length}/${total}
</p>
<div class="keyword-grid">
${matched
.map(
(kw) =>
`<div class="keyword-tag matched"><span>✅</span><span>${escapeHtml(
kw
)}</span></div>`
)
.join('')}
${missing
.map(
(kw) =>
`<div class="keyword-tag missing"><span>➕</span><span>${escapeHtml(
kw
)}</span></div>`
)
.join('')}
</div>
<div class="keyword-density">
<strong>Keyword Density</strong>: ${density.toFixed(2)}%
<br />
<span style="color: var(--text-muted); font-size: 0.875rem;">${densityLabel}</span>
</div>
`;
}
function updateRecommendations(recommendations) {
const recDisplay = document.getElementById('recommendations-display');
if (!recDisplay || !Array.isArray(recommendations)) return;
recDisplay.innerHTML = recommendations
.map((rec) => {
const type = rec.type || 'info';
const title = rec.title || '';
const text = rec.text || '';
return `
<div class="recommendation-item ${type}">
<div class="recommendation-header">
<span>${type === 'critical' ? '❌' : type === 'warning' ? '⚠️' : '💡'}</span>
<span>${escapeHtml(title)}</span>
</div>
<div class="recommendation-text">${escapeHtml(text)}</div>
</div>
`;
})
.join('');
}
// ========================
// Export & clipboard
// ========================
async function downloadResume(format) {
if (!optimizedResume || !optimizedResume.trim()) {
alert('Please optimize your resume first');
return;
}
if (format === 'pdf') {
await downloadAsPDF();
return;
}
if (format === 'docx') {
await downloadAsDOCX();
return;
}
const blob = new Blob([optimizedResume], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'optimized_resume.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function downloadAsPDF() {
try {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 15;
const maxLineWidth = pageWidth - margin * 2;
const lineHeight = 6;
let yPosition = margin;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
const lines = optimizedResume.split('\n');
let fontName = 'helvetica';
try {
const fontUrlReg =
'https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.66/fonts/Roboto/Roboto-Regular.ttf';
const fontUrlBold =
'https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.66/fonts/Roboto/Roboto-Medium.ttf';
const [bufReg, bufBold] = await Promise.all([
fetch(fontUrlReg).then((res) => res.arrayBuffer()),
fetch(fontUrlBold).then((res) => res.arrayBuffer()),
]);
const toBase64 = (buffer) => {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
const chunk = 8192;
for (let i = 0; i < len; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return window.btoa(binary);
};
doc.addFileToVFS('Roboto-Regular.ttf', toBase64(bufReg));
doc.addFileToVFS('Roboto-Bold.ttf', toBase64(bufBold));
doc.addFont('Roboto-Regular.ttf', 'Roboto', 'normal');
doc.addFont('Roboto-Bold.ttf', 'Roboto', 'bold');
fontName = 'Roboto';
doc.setFont(fontName, 'normal');
} catch (e) {
console.warn('Could not load Unicode font, falling back to Helvetica:', e);
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
if (!trimmedLine) {
yPosition += lineHeight * 0.5;
continue;
}
// Name/title at top
if (i < 3 && trimmedLine.length < 50 && /[A-Z]/.test(trimmedLine)) {
doc.setFontSize(16);
doc.setFont(fontName, 'bold');
if (yPosition + lineHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin;
}
const textLines = doc.splitTextToSize(trimmedLine, maxLineWidth);
textLines.forEach((tl, idx) => {
doc.text(tl, margin, yPosition + idx * lineHeight);
});
yPosition += textLines.length * lineHeight + 2;
doc.setFontSize(10);
doc.setFont(fontName, 'normal');
continue;
}
// Section headings
if (
(trimmedLine === trimmedLine.toUpperCase() && trimmedLine.length < 60 && trimmedLine.length > 2) ||
trimmedLine.endsWith(':')
) {
yPosition += lineHeight * 0.5;
doc.setFontSize(12);
doc.setFont(fontName, 'bold');
doc.setTextColor(30, 64, 175);
if (yPosition + lineHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin;
}
const textLines = doc.splitTextToSize(trimmedLine, maxLineWidth);
textLines.forEach((tl, idx) => {
doc.text(tl, margin, yPosition + idx * lineHeight);
});
yPosition += textLines.length * lineHeight + 2;
doc.setFontSize(10);
doc.setFont(fontName, 'normal');
doc.setTextColor(0, 0, 0);
continue;
}
// Bullet points
if (/^[•●▪■◆%-]\s/.test(trimmedLine)) {
doc.setFont(fontName, 'normal');
if (yPosition + lineHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin;
}
const textLines = doc.splitTextToSize(trimmedLine, maxLineWidth - 5);
textLines.forEach((tl, idx) => {
doc.text(tl, margin + 5, yPosition + idx * lineHeight);
});
yPosition += textLines.length * lineHeight;
continue;
}
// Regular text
doc.setFont(fontName, 'normal');
if (yPosition + lineHeight > pageHeight - margin) {
doc.addPage();
yPosition = margin;
}
const textLines = doc.splitTextToSize(trimmedLine, maxLineWidth);
textLines.forEach((tl, idx) => {
doc.text(tl, margin, yPosition + idx * lineHeight);
});
yPosition += textLines.length * lineHeight;
}
doc.save('optimized_resume.pdf');
} catch (error) {
console.error('PDF export error:', error);
alert('Failed to create PDF: ' + error.message + '. Downloading as TXT instead.');
downloadResume('txt');
}
}
async function downloadAsDOCX() {
try {
if (!window.pyodide) {
alert('Please wait for the system to initialize.');
return;
}
try {
await window.pyodide.loadPackage(['micropip']);
await window.pyodide.runPythonAsync(`
import micropip
try:
await micropip.install('python-docx')
except Exception as e:
print('python-docx install error or already installed:', e)
`);
} catch (e) {
console.log('python-docx already installed or error:', e);
}
window.pyodide.globals.set('resume_text', optimizedResume);
const docxBytes = await window.pyodide.runPythonAsync(`
import io
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document()
lines = resume_text.split('\\n')
for line in lines:
line = line.strip()
if not line:
continue
if line == line.upper() and len(line) < 50 and len(line) > 3:
heading = doc.add_heading(line, level=2)
heading.runs[0].font.color.rgb = RGBColor(30, 64, 175)
elif line.startswith(('•', '●', '▪', '-', '–')):
p = doc.add_paragraph(line, style='List Bullet')
p.paragraph_format.left_indent = Pt(20)
else:
p = doc.add_paragraph(line)
p.paragraph_format.space_after = Pt(6)
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
bytes(buf.read())
`);
const uint8Array = new Uint8Array(docxBytes.toJs());
const blob = new Blob([uint8Array], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'optimized_resume.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('DOCX export error:', error);
alert('Failed to create DOCX. Downloading as TXT instead.');
downloadResume('txt');
}
}
function copyToClipboard() {
if (!optimizedResume) {
alert('Please optimize your resume first');
return;
}
navigator.clipboard
.writeText(optimizedResume)
.then(() => alert('✅ Copied to clipboard!'))
.catch((err) => {
console.error('Copy failed:', err);
alert('Failed to copy to clipboard');
});
}
// ========================
// Sample job & init
// ========================
function loadSampleJob() {
const textarea = document.getElementById('job-description-input');
if (!textarea) return;
textarea.value = `Senior Full-Stack Engineer
We are looking for an experienced Full-Stack Engineer to join our growing team. You will be responsible for designing, developing, and maintaining scalable web applications.
Requirements:
• 5+ years of software development experience
• Strong proficiency in JavaScript, Python, and SQL
• Experience with AWS, Docker, and Kubernetes
• Proven track record of building microservices architecture
• Excellent problem-solving and communication skills
• Experience with Agile/Scrum methodologies
Nice to have:
• Experience with React and Node.js
• Knowledge of CI/CD pipelines
• Experience leading technical teams
• Bachelor's degree in Computer Science or related field`;
textarea.dispatchEvent(new Event('input'));
}
document.addEventListener('DOMContentLoaded', () => {
setupFileUpload();
setupTextInputs();
});
// Expose functions used by inline HTML handlers on the global window object
window.toggleResumeTextarea = toggleResumeTextarea;
window.optimizeResume = optimizeResume;
window.downloadResume = downloadResume;
window.loadSampleJob = loadSampleJob;
window.copyToClipboard = copyToClipboard;
window.switchView = switchView;
</script>
import json
import inspect
import sys
from typing import Any, Dict, List, Optional
# Global tool function cache
toolfunctions: Optional[List[Any]] = None
def pythontypetojsontype(py_type: Any) -> str:
"""Map Python types to JSON schema types."""
import inspect as _inspect
origin = getattr(py_type, "__origin__", None)
if origin is list or origin is tuple:
return "array"
if origin is dict:
return "object"
if py_type in (int, float):
return "number"
if py_type is bool:
return "boolean"
if py_type is str:
return "string"
# Fallback
return "string"
def extractfunctionschema(func) -> Dict[str, Any]:
"""Build an OpenAI-style tool schema from a Python function."""
import inspect as _inspect
sig = _inspect.signature(func)
params = {}
required = []
for name, param in sig.parameters.items():
if name == "self":
continue
ann = param.annotation if param.annotation is not _inspect._empty else str
param_type = pythontypetojsontype(ann)
param_schema: Dict[str, Any] = {"type": param_type}
if param.default is not _inspect._empty:
param_schema["default"] = param.default
else:
required.append(name)
params[name] = param_schema
doc = _inspect.getdoc(func) or ""
parts = doc.split("\n\n", 1)
description = parts[0].strip() if parts else ""
return {
"type": "function",
"function": {
"name": func.__name__,
"description": description,
"parameters": {
"type": "object",
"properties": params,
"required": required,
},
},
}
def gettoolschemas() -> str:
"""
Export all tool function schemas in OpenAI format.
Used by the wllama JavaScript bridge to discover available tools.
Returns a JSON string.
"""
global toolfunctions
# Explicitly expose NO tools by setting toolfunctions = []
if toolfunctions is None:
currentmodule = sys.modules[__name__]
# Infrastructure / non‑tool names that must never be exposed as tools
INFRANAMES = {
"gettoolschemas",
"processuserquery",
"processuserquerywllama",
"process_user_query",
"pythontypetojsontype",
"extractfunctionschema",
}
toolfunctions = []
for name, obj in inspect.getmembers(currentmodule):
if inspect.isfunction(obj) and not name.startswith("_"):
if obj.__module__ == __name__ and name not in INFRANAMES:
toolfunctions.append(obj)
schemas: List[Dict[str, Any]] = []
for func in toolfunctions:
try:
functionschema = extractfunctionschema(func)
schemas.append(functionschema)
except Exception as e:
print(f"Warning: Failed to extract schema for {func.__name__}: {e}")
return json.dumps(schemas, indent=2)
async def processuserquerywllama(query: str, customprompt: str) -> str:
"""
Process query using JavaScript wllama agent infrastructure.
This calls window.processWllamaQuery from JS and returns the model text.
"""
from js import processWllamaQuery
try:
result = await processWllamaQuery(query, customprompt)
return str(result)
except Exception as e:
import traceback
traceback.print_exc()
return f"Error: {str(e)}. Make sure wllama model is loaded."
async def processuserquery(query: str) -> str:
"""
Unified query processor for ALL providers (OpenAI, Anthropic, Local).
- provider == "local": use the browser wllama + tools pipeline.
- provider in {"openai", "anthropic"}: use Python LangChain with tools.
"""
provider = globals().get("PROVIDER", "openai")
# LOCAL: wllama path (no LangChain in Python)
if provider == "local":
try:
# Custom prompt is optional; empty string lets JS decide system prompt.
result = await processuserquerywllama(query, "")
return result
except Exception as e:
print(f"WebLLM/Wllama Error: {str(e)}")
import traceback
traceback.print_exc()
return f"Error using local model: {str(e)}"
# REMOTE PROVIDERS (OpenAI / Anthropic) via LangChain
try:
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
apikey = globals().get("CURRENT_API_KEY")
if not apikey:
return "Please enter an API key to use AI-powered features."
if provider == "openai":
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-3.5-turbo", api_key=apikey, temperature=0.7)
elif provider == "anthropic":
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-haiku-20240307", api_key=apikey, temperature=0.7)
else:
return f"Unknown provider: {provider}"
# Auto-discover tool functions (same filter as gettoolschemas)
global toolfunctions
if toolfunctions is None:
currentmodule = sys.modules[__name__]
INFRANAMES = {
"gettoolschemas",
"processuserquery",
"processuserquerywllama",
"process_user_query",
"pythontypetojsontype",
"extractfunctionschema",
}
toolfunctions = []
for name, obj in inspect.getmembers(currentmodule):
if inspect.isfunction(obj) and not name.startswith("_"):
if obj.__module__ == __name__ and name not in INFRANAMES:
toolfunctions.append(obj)
# If no tools, simple chat path
if not toolfunctions:
response = llm.invoke([HumanMessage(content=query)])
return response.content
tools = [tool(func) for func in toolfunctions]
llmwithtools = llm.bind_tools(tools)
messages: List[Any] = [HumanMessage(content=query)]
maxiterations = 3
for _ in range(maxiterations):
response = llmwithtools.invoke(messages)
messages.append(response)
tool_calls = getattr(response, "tool_calls", None)
if not tool_calls:
# No more tools: final answer in response.content
break
# Map tool names to Python callables
toolmap = {func.__name__: func for func in toolfunctions}
for tool_call in tool_calls:
toolname = tool_call["name"]
toolargs = tool_call["args"]
if toolname in toolmap:
try:
result = toolmap[toolname](**toolargs)
messages.append(
ToolMessage(
content=str(result),
tool_call_id=tool_call["id"],
)
)
except Exception as e:
messages.append(
ToolMessage(
content=f"Error executing tool {toolname}: {str(e)}",
tool_call_id=tool_call["id"],
)
)
else:
messages.append(
ToolMessage(
content=f"Unknown tool {toolname}",
tool_call_id=tool_call["id"],
)
)
finalmessage = messages[-1]
if hasattr(finalmessage, "content"):
return finalmessage.content
return str(finalmessage)
except Exception as e:
import traceback
traceback.print_exc()
return f"Error processing query: {str(e)}"
# Optional alias so older JS that calls process_user_query still works
async def process_user_query(query: str) -> str:
return await processuserquery(query)
print("Unified agent initialized (provider={})".format(globals().get("PROVIDER", "openai")))