-
-
Notifications
You must be signed in to change notification settings - Fork 9.5k
feat: optional resume-ops mode for high-quality tailoring #640
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # Mode: resume-ops — High-Quality Tailoring via External API | ||
|
|
||
| Use this mode when the user wants a better-tailored resume. This mode leverages the specialized `resume-ops` service. | ||
|
|
||
| **The AI agent is responsible for the entire technical lifecycle**: starting the service, ensuring data compatibility, and executing the tailoring. The user should only need to provide the JD. | ||
|
|
||
| ## Pipeline | ||
|
|
||
| 1. **Prepare Job Description (JD)**: | ||
| - Identify the JD text or URL. | ||
| - If it's a URL, use `scan.mjs` or relevant tools to extract the text. | ||
|
|
||
| 2. **Technical Setup (Invisible to User)**: | ||
| - **Cloning & Service**: Run `node start-resume-ops.mjs`. This script will: | ||
| - Automatically clone `resume-ops` from GitLab if missing. | ||
| - Automatically configure the `.env` for local execution. | ||
| - Start the service using `uv` on port 8000. | ||
| - **Docker/Podman**: This integration runs `resume-ops` directly via Python/uv for simplicity. **Docker or Podman are NOT required** for this setup, making it easy for most users. | ||
| - **JSON Resume**: Check for `resume-ops/.local/master-resume.json`. If missing, generate it immediately from `cv.md` and `config/profile.yml`. | ||
|
|
||
| 3. **Execute Tailoring**: | ||
| - Determine the output PDF path: `output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf`. | ||
| - Use `config/profile.yml` to get `{candidate}` (normalized to kebab-case) and `{company}`. | ||
| - Run the helper script: | ||
| ```bash | ||
| node resume-ops.mjs --resume resume-ops/.local/master-resume.json --jd "{JD_TEXT}" --output output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf | ||
| ``` | ||
| - If `config/profile.yml` has a `cv.theme` value, pass it via `--theme`. | ||
|
|
||
| 4. **Update Tracker**: | ||
| - If this is a new application, create a TSV entry in `batch/tracker-additions/` and run `node merge-tracker.mjs`. | ||
| - If it's an existing application in `data/applications.md`, update the `PDF` column from ❌ to ✅. | ||
|
|
||
| ## JSON Resume Mapping Guide (if generating) | ||
|
|
||
| | JSON Field | Source in Career-Ops | | ||
| | ----------- | --------------------------------------------------------------- | | ||
| | `basics` | `config/profile.yml` (name, email, phone, website, location) | | ||
| | `work` | `cv.md` -> Experience section. Map each role to a `work` entry. | | ||
| | `education` | `cv.md` -> Education section. | | ||
| | `skills` | `cv.md` -> Technical Skills section. | | ||
| | `projects` | `cv.md` -> Projects section. | | ||
|
|
||
| ## Why use this mode? | ||
|
|
||
| - **Better Quality**: Uses a specialized multi-step LLM pipeline for tailoring. | ||
| - **ATS-Optimized**: Generates clean PDFs via the `resumed` engine. | ||
| - **Standardized**: Produces a `resume.json` that can be used with other JSON Resume tools. | ||
|
|
||
| ## Safety & Ethics | ||
|
|
||
| - NEVER invent experience or skills. | ||
| - Only reformulate existing content using JD keywords. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
|
|
||
| async function pollTask(taskId) { | ||
| const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`; | ||
| while (true) { | ||
| const response = await fetch(url); | ||
| if (!response.ok) throw new Error(`Polling failed: ${response.status}`); | ||
| const data = await response.json(); | ||
| if (data.status === 'completed') return data; | ||
| if (data.status === 'failed') throw new Error(`Task failed: ${JSON.stringify(data.error)}`); | ||
| process.stdout.write('.'); | ||
| await new Promise(r => setTimeout(r, 2000)); | ||
| } | ||
| } | ||
|
Comment on lines
+4
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Line 5 interpolates 🛡️ Proposed fix to validate taskId async function pollTask(taskId) {
+ // Validate taskId format (alphanumeric, hyphens, underscores only)
+ if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
+ throw new Error(`Invalid task ID format: ${taskId}`);
+ }
const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`;
while (true) {🤖 Prompt for AI Agents |
||
|
|
||
| async function main() { | ||
| const args = process.argv.slice(2); | ||
| const params = {}; | ||
| for (let i = 0; i < args.length; i++) { | ||
| if (args[i].startsWith('--')) { | ||
| params[args[i].slice(2)] = args[i + 1]; | ||
| i++; | ||
| } | ||
| } | ||
|
|
||
| if (!params.resume || !params.jd || !params.output) { | ||
| console.error('Usage: node resume-ops.mjs --resume <path> --jd <path|text> --output <path> [--theme <name>]'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let resume; | ||
| try { | ||
| resume = JSON.parse(fs.readFileSync(params.resume, 'utf8')); | ||
| } catch (e) { | ||
| console.error(`❌ Error reading resume: ${e.message}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let jd = params.jd; | ||
| if (fs.existsSync(jd)) { | ||
| jd = fs.readFileSync(jd, 'utf8'); | ||
| } | ||
|
|
||
| console.log('📡 Sending tailoring request to Resume Ops API...'); | ||
|
|
||
| try { | ||
| const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| resume, | ||
| job_description: jd, | ||
| theme: params.theme || 'jsonresume-theme-stackoverflow' | ||
| }) | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const err = await response.text(); | ||
| throw new Error(`API Error: ${response.status} ${err}`); | ||
| } | ||
|
|
||
| let data = await response.json(); | ||
|
|
||
| if (data.task_id && !data.pdf_base64) { | ||
| console.log(`⏳ Task queued (ID: ${data.task_id}). Polling for completion...`); | ||
| data = await pollTask(data.task_id); | ||
| console.log('\n✅ Task completed.'); | ||
| } | ||
|
|
||
| if (data.pdf_base64) { | ||
| fs.mkdirSync(path.dirname(params.output), { recursive: true }); | ||
| fs.writeFileSync(params.output, Buffer.from(data.pdf_base64, 'base64')); | ||
| console.log(`📄 PDF successfully saved to ${params.output}`); | ||
|
|
||
| // Also save the tailored JSON if available | ||
| if (data.resume) { | ||
| const jsonPath = params.output.replace('.pdf', '.json'); | ||
| fs.writeFileSync(jsonPath, JSON.stringify(data.resume, null, 2)); | ||
| console.log(`📝 Tailored JSON saved to ${jsonPath}`); | ||
| } | ||
|
Comment on lines
+77
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use case-insensitive path handling for JSON output. Line 78 uses 🔧 Proposed fix using proper path manipulation // Also save the tailored JSON if available
if (data.resume) {
- const jsonPath = params.output.replace('.pdf', '.json');
+ const parsed = path.parse(params.output);
+ const jsonPath = path.join(parsed.dir, parsed.name + '.json');
fs.writeFileSync(jsonPath, JSON.stringify(data.resume, null, 2));
console.log(`📝 Tailored JSON saved to ${jsonPath}`);
}🤖 Prompt for AI Agents |
||
| } else { | ||
| throw new Error('No PDF content received from API'); | ||
| } | ||
| } catch (err) { | ||
| console.error(`❌ Error: ${err.message}`); | ||
| process.exit(1); | ||
| } | ||
|
Comment on lines
+45
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Consider adding request timeout for external API calls. The ⏱️ Proposed fix to add timeout handling console.log('📡 Sending tailoring request to Resume Ops API...');
try {
- const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout
+ const response = await fetch('http://127.0.0.1:8000/api/v1/tailor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resume,
job_description: jd,
theme: params.theme || 'jsonresume-theme-stackoverflow'
- })
+ }),
+ signal: controller.signal
});
+ clearTimeout(timeoutId);
if (!response.ok) {Also add a maximum iteration limit to async function pollTask(taskId) {
if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
throw new Error(`Invalid task ID format: ${taskId}`);
}
const url = `http://127.0.0.1:8000/api/v1/tasks/${taskId}`;
+ let attempts = 0;
+ const MAX_ATTEMPTS = 60; // 2 minutes with 2s intervals
while (true) {
+ if (attempts++ >= MAX_ATTEMPTS) {
+ throw new Error('Polling timeout: task did not complete in time');
+ }
const response = await fetch(url);🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| We are looking for a Senior Product Manager with experience in AI and automation. | ||
| The ideal candidate has experience with Python, LLMs, and high-quality resume generation. | ||
| Key skills: Product Management, AI, Python, REST APIs. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,125 @@ | ||||||||||||||||||||||||||||||||||||
| import { spawn, spawnSync } from 'child_process'; | ||||||||||||||||||||||||||||||||||||
| import net from 'net'; | ||||||||||||||||||||||||||||||||||||
| import path from 'path'; | ||||||||||||||||||||||||||||||||||||
| import fs from 'fs'; | ||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from 'url'; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||||||||||||||||||||||||||||||||||||
| const PORT = 8000; | ||||||||||||||||||||||||||||||||||||
| const RESUME_OPS_DIR = path.resolve(__dirname, 'resume-ops'); | ||||||||||||||||||||||||||||||||||||
| const RESUME_OPS_REPO = 'https://gitlab.com/CovaiLabs/resume-ops.git'; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function checkPort(port) { | ||||||||||||||||||||||||||||||||||||
| return new Promise((resolve) => { | ||||||||||||||||||||||||||||||||||||
| const socket = new net.Socket(); | ||||||||||||||||||||||||||||||||||||
| socket.setTimeout(1000); | ||||||||||||||||||||||||||||||||||||
| socket.on('connect', () => { | ||||||||||||||||||||||||||||||||||||
| socket.destroy(); | ||||||||||||||||||||||||||||||||||||
| resolve(true); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| socket.on('timeout', () => { | ||||||||||||||||||||||||||||||||||||
| socket.destroy(); | ||||||||||||||||||||||||||||||||||||
| resolve(false); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| socket.on('error', () => { | ||||||||||||||||||||||||||||||||||||
| socket.destroy(); | ||||||||||||||||||||||||||||||||||||
| resolve(false); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| socket.connect(port, '127.0.0.1'); | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| async function main() { | ||||||||||||||||||||||||||||||||||||
| const isRunning = await checkPort(PORT); | ||||||||||||||||||||||||||||||||||||
| if (isRunning) { | ||||||||||||||||||||||||||||||||||||
| console.log(`✅ Resume Ops is already running on port ${PORT}.`); | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Check if resume-ops directory exists | ||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(RESUME_OPS_DIR)) { | ||||||||||||||||||||||||||||||||||||
| console.log(`📂 resume-ops directory not found. Cloning from ${RESUME_OPS_REPO}...`); | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| spawnSync('git', ['clone', RESUME_OPS_REPO, RESUME_OPS_DIR], { stdio: 'inherit' }); | ||||||||||||||||||||||||||||||||||||
| console.log('✅ Clone complete.'); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Initialize .env if missing | ||||||||||||||||||||||||||||||||||||
| const envPath = path.join(RESUME_OPS_DIR, '.env'); | ||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(envPath)) { | ||||||||||||||||||||||||||||||||||||
| console.log('📝 Initializing .env from .env.example...'); | ||||||||||||||||||||||||||||||||||||
| const exampleEnv = fs.readFileSync(path.join(RESUME_OPS_DIR, '.env.example'), 'utf8'); | ||||||||||||||||||||||||||||||||||||
| let localEnv = exampleEnv.replace('DATA_DIR=/data', 'DATA_DIR=./data'); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Sync common AI keys from the current environment | ||||||||||||||||||||||||||||||||||||
| const keysToSync = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OPENAI_BASE_URL']; | ||||||||||||||||||||||||||||||||||||
| keysToSync.forEach(key => { | ||||||||||||||||||||||||||||||||||||
| if (process.env[key]) { | ||||||||||||||||||||||||||||||||||||
| console.log(`🔑 Syncing ${key} from environment...`); | ||||||||||||||||||||||||||||||||||||
| // Replace the empty or placeholder value | ||||||||||||||||||||||||||||||||||||
| localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${process.env[key]}`); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+54
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape special characters when syncing API keys to Line 59 uses 🔒 Proposed fix to escape replacement string keysToSync.forEach(key => {
if (process.env[key]) {
console.log(`🔑 Syncing ${key} from environment...`);
- // Replace the empty or placeholder value
- localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${process.env[key]}`);
+ // Replace the empty or placeholder value (escape special regex chars in value)
+ const escapedValue = process.env[key].replace(/\$/g, '$$$$');
+ localEnv = localEnv.replace(new RegExp(`${key}=.*`), `${key}=${escapedValue}`);
}
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| fs.writeFileSync(envPath, localEnv); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Create data directories proactively | ||||||||||||||||||||||||||||||||||||
| const dataDir = path.join(RESUME_OPS_DIR, 'data'); | ||||||||||||||||||||||||||||||||||||
| const jobsDir = path.join(dataDir, 'jobs'); | ||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(jobsDir)) { | ||||||||||||||||||||||||||||||||||||
| console.log('📂 Creating resume-ops data directories...'); | ||||||||||||||||||||||||||||||||||||
| fs.mkdirSync(jobsDir, { recursive: true }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||
| console.error(`❌ Failed to clone resume-ops: ${err.message}`); | ||||||||||||||||||||||||||||||||||||
| process.exit(1); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Check if resumed is installed | ||||||||||||||||||||||||||||||||||||
| const hasResumed = spawnSync('which', ['resumed']).status === 0 || fs.existsSync(path.resolve(__dirname, 'node_modules', '.bin', 'resumed')); | ||||||||||||||||||||||||||||||||||||
| if (!hasResumed) { | ||||||||||||||||||||||||||||||||||||
| console.log('📦 resumed (PDF engine) not found. Installing locally...'); | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| spawnSync('npm', ['install', 'resumed'], { cwd: __dirname, stdio: 'inherit' }); | ||||||||||||||||||||||||||||||||||||
| console.log('✅ resumed installed.'); | ||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||
| console.error(`❌ Failed to install resumed: ${err.message}`); | ||||||||||||||||||||||||||||||||||||
| // Don't exit, maybe it's already there but which failed | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Ensure node_modules/.bin is in PATH for the child process | ||||||||||||||||||||||||||||||||||||
| const env = { ...process.env }; | ||||||||||||||||||||||||||||||||||||
| const binPath = path.resolve(__dirname, 'node_modules', '.bin'); | ||||||||||||||||||||||||||||||||||||
| env.PATH = `${binPath}:${env.PATH}`; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| console.log(`🚀 Starting Resume Ops service in ${RESUME_OPS_DIR}...`); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const logFile = path.resolve(__dirname, 'resume-ops.log'); | ||||||||||||||||||||||||||||||||||||
| const out = fs.openSync(logFile, 'a'); | ||||||||||||||||||||||||||||||||||||
| const err = fs.openSync(logFile, 'a'); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const child = spawn('uv', ['run', 'python', '-m', 'resume_ops_api'], { | ||||||||||||||||||||||||||||||||||||
| cwd: RESUME_OPS_DIR, | ||||||||||||||||||||||||||||||||||||
| stdio: ['ignore', out, err], | ||||||||||||||||||||||||||||||||||||
| detached: true, | ||||||||||||||||||||||||||||||||||||
| env, | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| child.unref(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| console.log('⏳ Waiting for service to initialize...'); | ||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < 10; i++) { | ||||||||||||||||||||||||||||||||||||
| await new Promise(r => setTimeout(r, 1000)); | ||||||||||||||||||||||||||||||||||||
| if (await checkPort(PORT)) { | ||||||||||||||||||||||||||||||||||||
| console.log(`✅ Resume Ops service is now ready on port ${PORT}.`); | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| console.error('❌ Failed to start Resume Ops service. Please start it manually with `uv run python -m resume_ops_api` in the resume-ops directory.'); | ||||||||||||||||||||||||||||||||||||
| process.exit(1); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| main(); | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Add blank lines around the fenced code block.
The code block should be surrounded by blank lines for proper Markdown rendering and consistency with linting rules.
📝 Proposed fix
3. **Execute Tailoring**: - Determine the output PDF path: `output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf`. - Use `config/profile.yml` to get `{candidate}` (normalized to kebab-case) and `{company}`. - Run the helper script: + ```bash node resume-ops.mjs --resume resume-ops/.local/master-resume.json --jd "{JD_TEXT}" --output output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf ``` + - If `config/profile.yml` has a `cv.theme` value, pass it via `--theme`.🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 25-25: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
[warning] 27-27: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
🤖 Prompt for AI Agents