Skip to content
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to ApplyPilot will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- Tailored resumes and cover letters get collision-free filenames, and parallel
apply workers no longer share one upload path (was: one job's resume could be
sent to another employer).
- The fabrication watchlist is word-boundary matched and respects the candidate's
real skills (no more false hits on "scalable"/"guardrails"; legitimate C++/C#
skills allowed).
- Cover-letter PDFs render the actual letter body (were near-empty).
- Sequential `run` no longer silently caps tailoring/cover letters at 20 jobs.
- Jobs stranded `in_progress` by a crashed run are recovered at apply startup.
- One failing site no longer aborts the whole smart-extract stage.

## [0.2.0] - 2026-02-17

### Added
Expand Down
33 changes: 30 additions & 3 deletions src/applypilot/apply/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def gen_prompt(target_url: str, min_score: int = 7,
if txt_path and txt_path.exists():
resume_text = txt_path.read_text(encoding="utf-8")

prompt = prompt_mod.build_prompt(job=job, tailored_resume=resume_text)
prompt = prompt_mod.build_prompt(job=job, tailored_resume=resume_text, worker_id=worker_id)

# Release the lock so the job stays available
release_lock(job["url"])
Expand Down Expand Up @@ -272,6 +272,25 @@ def mark_job(url: str, status: str, reason: str | None = None) -> None:
conn.commit()


def reset_stale_locks() -> int:
"""Clear jobs stuck in 'in_progress' from a previous crashed run.

All workers live in this process, so anything still 'in_progress' at startup
is by definition stale (the worker that held it is gone). Returns NULL so the
job is eligible again.

Returns:
Number of stale locks cleared.
"""
conn = get_connection()
cursor = conn.execute(
"UPDATE jobs SET apply_status = NULL, agent_id = NULL "
"WHERE apply_status = 'in_progress'"
)
conn.commit()
return cursor.rowcount


def reset_failed() -> int:
"""Reset all failed jobs so they can be retried.

Expand Down Expand Up @@ -310,11 +329,16 @@ def run_job(job: dict, port: int, worker_id: int = 0,
if txt_path and txt_path.exists():
resume_text = txt_path.read_text(encoding="utf-8")

# Reset the worker dir FIRST: build_prompt copies the resume/cover PDFs into
# APPLY_WORKER_DIR/worker-{id}/current, which reset_worker_dir would wipe.
worker_dir = reset_worker_dir(worker_id)

# Build the prompt
agent_prompt = prompt_mod.build_prompt(
job=job,
tailored_resume=resume_text,
dry_run=dry_run,
worker_id=worker_id,
)

# Write per-worker MCP config
Expand Down Expand Up @@ -347,8 +371,6 @@ def run_job(job: dict, port: int, worker_id: int = 0,
env.pop("CLAUDECODE", None)
env.pop("CLAUDE_CODE_ENTRYPOINT", None)

worker_dir = reset_worker_dir(worker_id)

update_state(worker_id, status="applying", job_title=job["title"],
company=job.get("site", ""), score=job.get("fit_score", 0),
start_time=time.time(), actions=0, last_action="starting")
Expand Down Expand Up @@ -674,6 +696,11 @@ def main(limit: int = 1, target_url: str | None = None,
config.ensure_dirs()
console = Console()

# Recover jobs stranded 'in_progress' by a previous crashed run.
recovered = reset_stale_locks()
if recovered:
console.print(f"[yellow]Recovered {recovered} stale in-progress job(s)[/yellow]")

if continuous:
effective_limit = 0
mode_label = "continuous"
Expand Down
6 changes: 4 additions & 2 deletions src/applypilot/apply/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ def _build_captcha_section() -> str:

def build_prompt(job: dict, tailored_resume: str,
cover_letter: str | None = None,
dry_run: bool = False) -> str:
dry_run: bool = False,
worker_id: int = 0) -> str:
"""Build the full instruction prompt for the apply agent.

Loads the user profile and search config internally. All personal data
Expand Down Expand Up @@ -451,7 +452,8 @@ def build_prompt(job: dict, tailored_resume: str,
# Copy to a clean filename for upload (recruiters see the filename)
full_name = personal["full_name"]
name_slug = full_name.replace(" ", "_")
dest_dir = config.APPLY_WORKER_DIR / "current"
# Per-worker upload dir so parallel workers don't race on one shared path.
dest_dir = config.APPLY_WORKER_DIR / f"worker-{worker_id}" / "current"
dest_dir.mkdir(parents=True, exist_ok=True)
upload_pdf = dest_dir / f"{name_slug}_Resume.pdf"
shutil.copy(str(src_pdf), str(upload_pdf))
Expand Down
18 changes: 15 additions & 3 deletions src/applypilot/discovery/smartextract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ def _run_all(
results: list[dict] = []
total_new = 0
total_existing = 0
errors = 0

def _process_result(r: dict, target: dict) -> None:
nonlocal total_new, total_existing
Expand All @@ -1052,7 +1053,13 @@ def _process_result(r: dict, target: dict) -> None:
}
for future in as_completed(future_to_target):
target = future_to_target[future]
r = future.result()
# One flaky site must not abort the whole stage.
try:
r = future.result()
except Exception as e:
log.warning("Site %s failed: %s -- continuing", target["name"], e)
errors += 1
continue
results.append(r)
_process_result(r, target)
else:
Expand All @@ -1063,7 +1070,12 @@ def _process_result(r: dict, target: dict) -> None:
label = f"{target['name']} [{target['query']}]"
log.info("[%d/%d] %s", i + 1, len(targets), label)

r = _run_one_site(target["name"], target["url"])
try:
r = _run_one_site(target["name"], target["url"])
except Exception as e:
log.warning("Site %s failed: %s -- continuing", target["name"], e)
errors += 1
continue
results.append(r)
_process_result(r, target)

Expand All @@ -1080,7 +1092,7 @@ def _process_result(r: dict, target: dict) -> None:
log.info("%d/%d PASS", passed, len(results))

return {"total_new": total_new, "total_existing": total_existing,
"passed": passed, "total": len(results)}
"passed": passed, "total": len(results), "errors": errors}


# -- Public entry point ------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions src/applypilot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def _run_tailor(min_score: int = 7, validation_mode: str = "normal") -> dict:
"""Stage: Resume tailoring — generate tailored resumes for high-fit jobs."""
try:
from applypilot.scoring.tailor import run_tailoring
run_tailoring(min_score=min_score, validation_mode=validation_mode)
run_tailoring(min_score=min_score, limit=0, validation_mode=validation_mode)
return {"status": "ok"}
except Exception as e:
log.error("Tailoring failed: %s", e)
Expand All @@ -136,7 +136,7 @@ def _run_cover(min_score: int = 7, validation_mode: str = "normal") -> dict:
"""Stage: Cover letter generation."""
try:
from applypilot.scoring.cover_letter import run_cover_letters
run_cover_letters(min_score=min_score, validation_mode=validation_mode)
run_cover_letters(min_score=min_score, limit=0, validation_mode=validation_mode)
return {"status": "ok"}
except Exception as e:
log.error("Cover letter generation failed: %s", e)
Expand Down
30 changes: 18 additions & 12 deletions src/applypilot/scoring/cover_letter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from applypilot.config import COVER_LETTER_DIR, RESUME_PATH, load_profile
from applypilot.database import get_connection, get_jobs_by_stage
from applypilot.llm import get_client
from applypilot.scoring.tailor import make_filename_prefix
from applypilot.scoring.validator import (
BANNED_WORDS,
LLM_LEAK_PHRASES,
Expand Down Expand Up @@ -201,16 +202,21 @@ def run_cover_letters(min_score: int = 7, limit: int = 20,
resume_text = RESUME_PATH.read_text(encoding="utf-8")
conn = get_connection()

# Fetch jobs that have tailored resumes but no cover letter yet
jobs = conn.execute(
# Fetch jobs that have tailored resumes but no cover letter yet.
# limit <= 0 means "all": a literal LIMIT 0 would return zero rows.
sql = (
"SELECT * FROM jobs "
"WHERE fit_score >= ? AND tailored_resume_path IS NOT NULL "
"AND full_description IS NOT NULL "
"AND (cover_letter_path IS NULL OR cover_letter_path = '') "
"AND COALESCE(cover_attempts, 0) < ? "
"ORDER BY fit_score DESC LIMIT ?",
(min_score, MAX_ATTEMPTS, limit),
).fetchall()
"ORDER BY fit_score DESC"
)
params: list = [min_score, MAX_ATTEMPTS]
if limit > 0:
sql += " LIMIT ?"
params.append(limit)
jobs = conn.execute(sql, params).fetchall()

if not jobs:
log.info("No jobs needing cover letters (score >= %d).", min_score)
Expand All @@ -237,19 +243,19 @@ def run_cover_letters(min_score: int = 7, limit: int = 20,
letter = generate_cover_letter(resume_text, job, profile,
validation_mode=validation_mode)

# Build safe filename prefix
safe_title = re.sub(r"[^\w\s-]", "", job["title"])[:50].strip().replace(" ", "_")
safe_site = re.sub(r"[^\w\s-]", "", job["site"])[:20].strip().replace(" ", "_")
prefix = f"{safe_site}_{safe_title}"
# Build safe, collision-free filename prefix
prefix = make_filename_prefix(job)

cl_path = COVER_LETTER_DIR / f"{prefix}_CL.txt"
cl_path.write_text(letter, encoding="utf-8")

# Generate PDF (best-effort)
# Generate PDF (best-effort). Use the letter renderer, NOT the resume
# converter, which drops a cover letter's body.
pdf_path = None
try:
from applypilot.scoring.pdf import convert_to_pdf
pdf_path = str(convert_to_pdf(cl_path))
from applypilot.scoring.pdf import convert_letter_to_pdf
applicant_name = profile.get("personal", {}).get("full_name", "")
pdf_path = str(convert_letter_to_pdf(cl_path, applicant_name=applicant_name))
except Exception:
log.debug("PDF generation failed for %s", cl_path, exc_info=True)

Expand Down
38 changes: 38 additions & 0 deletions src/applypilot/scoring/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import logging
import re
from pathlib import Path

from applypilot.config import TAILORED_DIR
Expand Down Expand Up @@ -390,6 +391,43 @@ def convert_to_pdf(
return out


def _letter_html(text: str, applicant_name: str) -> str:
"""Build a simple, correctly-structured HTML letter.

Cover letters have no resume structure (no SUMMARY line, no ALL-CAPS section
headers), so parse_resume() drops their body. This renders the prose as
paragraphs under a modest name header, escaping all content.
"""
import html as _html

paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
body = "\n".join(
f"<p>{_html.escape(p).replace(chr(10), '<br>')}</p>" for p in paragraphs
)
name = _html.escape(applicant_name)
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
@page {{ margin: 1in; }}
body {{ font-family: 'Calibri', 'Segoe UI', Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #222; }}
.name {{ font-size: 14pt; font-weight: 700; margin-bottom: 1.5em; }}
p {{ margin: 0 0 1em 0; }}
</style></head><body>
<div class="name">{name}</div>
{body}
</body></html>"""


def convert_letter_to_pdf(txt_path: Path, applicant_name: str,
output_path: Path | None = None) -> Path:
"""Render a cover-letter .txt to a properly formatted PDF."""
txt_path = Path(txt_path)
html = _letter_html(txt_path.read_text(encoding="utf-8"), applicant_name)
out = Path(output_path or txt_path.with_suffix(".pdf"))
render_pdf(html, str(out))
log.info("Cover letter PDF generated: %s", out)
return out


def batch_convert(limit: int = 50) -> int:
"""Convert .txt files in TAILORED_DIR that don't have corresponding PDFs.

Expand Down
20 changes: 16 additions & 4 deletions src/applypilot/scoring/tailor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
to avoid apologetic spirals.
"""

import hashlib
import json
import logging
import re
Expand All @@ -32,6 +33,19 @@
MAX_ATTEMPTS = 5 # max cross-run retries before giving up


def make_filename_prefix(job: dict) -> str:
"""Build a collision-free filename prefix for a job's generated artifacts.

Two postings with the same title from the same board would otherwise share
a path and overwrite each other, sending one employer the resume tailored
for another. A short hash of the (unique) job URL disambiguates them.
"""
safe_title = re.sub(r"[^\w\s-]", "", job["title"])[:50].strip().replace(" ", "_")
safe_site = re.sub(r"[^\w\s-]", "", job["site"])[:20].strip().replace(" ", "_")
url_hash = hashlib.sha1(job["url"].encode()).hexdigest()[:8]
return f"{safe_site}_{safe_title}_{url_hash}"


# ── Prompt Builders (profile-driven) ──────────────────────────────────────

def _build_tailor_prompt(profile: dict) -> str:
Expand Down Expand Up @@ -490,10 +504,8 @@ def run_tailoring(min_score: int = 7, limit: int = 20,
tailored, report = tailor_resume(resume_text, job, profile,
validation_mode=validation_mode)

# Build safe filename prefix
safe_title = re.sub(r"[^\w\s-]", "", job["title"])[:50].strip().replace(" ", "_")
safe_site = re.sub(r"[^\w\s-]", "", job["site"])[:20].strip().replace(" ", "_")
prefix = f"{safe_site}_{safe_title}"
# Build safe, collision-free filename prefix
prefix = make_filename_prefix(job)

# Save tailored resume text
txt_path = TAILORED_DIR / f"{prefix}.txt"
Expand Down
Loading