Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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
- Location filter no longer discards nearly every job — it reads the documented
`location.accept_patterns` schema and treats an empty accept list as "keep".
- The real company name is stored and used in scoring, tailoring, cover letters,
the apply prompt, and the dashboard (was showing the job board, e.g. "linkedin").
- Scoring failures (rate limits, parse errors) leave jobs pending for retry
instead of writing a permanent `fit_score=0`; scores commit incrementally so an
interrupt doesn't discard the run; markdown-decorated scores parse correctly.

## [0.2.0] - 2026-02-17

### Added
Expand Down
6 changes: 3 additions & 3 deletions src/applypilot/apply/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def acquire_job(target_url: str | None = None, min_score: int = 7,
if target_url:
like = f"%{target_url.split('?')[0].rstrip('/')}%"
row = conn.execute("""
SELECT url, title, site, application_url, tailored_resume_path,
SELECT url, title, company, site, application_url, tailored_resume_path,
fit_score, location, full_description, cover_letter_path
FROM jobs
WHERE (url = ? OR application_url = ? OR application_url LIKE ? OR url LIKE ?)
Expand All @@ -128,7 +128,7 @@ def acquire_job(target_url: str | None = None, min_score: int = 7,
url_clauses = " ".join(f"AND url NOT LIKE ?" for _ in blocked_patterns)
params.extend(blocked_patterns)
row = conn.execute(f"""
SELECT url, title, site, application_url, tailored_resume_path,
SELECT url, title, company, site, application_url, tailored_resume_path,
fit_score, location, full_description, cover_letter_path
FROM jobs
WHERE tailored_resume_path IS NOT NULL
Expand Down Expand Up @@ -350,7 +350,7 @@ def run_job(job: dict, port: int, worker_id: int = 0,
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),
company=job.get("company") or job.get("site", ""), score=job.get("fit_score", 0),
start_time=time.time(), actions=0, last_action="starting")
add_event(f"[W{worker_id}] Starting: {job['title'][:40]} @ {job.get('site', '')}")

Expand Down
2 changes: 1 addition & 1 deletion src/applypilot/apply/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ def build_prompt(job: dict, tailored_resume: str,
== JOB ==
URL: {job.get('application_url') or job['url']}
Title: {job['title']}
Company: {job.get('site', 'Unknown')}
Company: {job.get('company') or job.get('site', 'Unknown')}
Fit Score: {job.get('fit_score', 'N/A')}/10

== FILES ==
Expand Down
8 changes: 5 additions & 3 deletions src/applypilot/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def init_db(db_path: Path | str | None = None) -> sqlite3.Connection:
-- Discovery stage (smart_extract / job_search)
url TEXT PRIMARY KEY,
title TEXT,
company TEXT,
salary TEXT,
description TEXT,
location TEXT,
Expand Down Expand Up @@ -147,6 +148,7 @@ def init_db(db_path: Path | str | None = None) -> sqlite3.Connection:
# Discovery
"url": "TEXT PRIMARY KEY",
"title": "TEXT",
"company": "TEXT",
"salary": "TEXT",
"description": "TEXT",
"location": "TEXT",
Expand Down Expand Up @@ -349,9 +351,9 @@ def store_jobs(conn: sqlite3.Connection, jobs: list[dict],
continue
try:
conn.execute(
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), job.get("salary"), job.get("description"),
"INSERT INTO jobs (url, title, company, salary, description, location, site, strategy, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), job.get("company"), job.get("salary"), job.get("description"),
job.get("location"), site, strategy, now),
)
new += 1
Expand Down
49 changes: 8 additions & 41 deletions src/applypilot/discovery/jobspy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from applypilot import config
from applypilot.database import get_connection, init_db, store_jobs
from applypilot.locfilter import load_location_filter, location_ok

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -75,44 +76,10 @@ def _scrape_with_retry(kwargs: dict, max_retries: int = 2, backoff: float = 5.0)


# -- Location filtering ------------------------------------------------------

def _load_location_config(search_cfg: dict) -> tuple[list[str], list[str]]:
"""Extract accept/reject location lists from search config.

Falls back to sensible defaults if not defined in the YAML.
"""
accept = search_cfg.get("location_accept", [])
reject = search_cfg.get("location_reject_non_remote", [])
return accept, reject


def _location_ok(location: str | None, accept: list[str], reject: list[str]) -> bool:
"""Check if a job location passes the user's location filter.

Remote jobs are always accepted. Non-remote jobs must match an accept
pattern and not match a reject pattern.
"""
if not location:
return True # unknown location -- keep it, let scorer decide

loc = location.lower()

# Remote jobs always OK
if any(r in loc for r in ("remote", "anywhere", "work from home", "wfh", "distributed")):
return True

# Reject non-remote matches
for r in reject:
if r.lower() in loc:
return False

# Accept matches
for a in accept:
if a.lower() in loc:
return True

# No match -- reject unknown
return False
# Canonical implementation lives in applypilot.locfilter; these aliases keep the
# existing call sites unchanged.
_load_location_config = load_location_filter
_location_ok = location_ok


# -- DB storage (JobSpy DataFrame -> SQLite) ---------------------------------
Expand Down Expand Up @@ -168,10 +135,10 @@ def store_jobspy_results(conn: sqlite3.Connection, df, source_label: str) -> tup

try:
conn.execute(
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, discovered_at, "
"INSERT INTO jobs (url, title, company, salary, description, location, site, strategy, discovered_at, "
"full_description, application_url, detail_scraped_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, title, salary, description, location_str, site_label, strategy, now,
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, title, company, salary, description, location_str, site_label, strategy, now,
full_description, apply_url, detail_scraped_at),
)
new += 1
Expand Down
35 changes: 12 additions & 23 deletions src/applypilot/discovery/smartextract.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from applypilot.config import CONFIG_DIR
from applypilot.database import get_connection, init_db, store_jobs, get_stats
from applypilot.llm import get_client
from applypilot.locfilter import load_location_filter, location_ok as _location_ok

log = logging.getLogger(__name__)

Expand All @@ -49,28 +50,13 @@
# -- Location filtering -------------------------------------------------------

def _load_location_filter(search_cfg: dict | None = None):
"""Load location accept/reject lists from search config."""
"""Load location accept/reject lists, defaulting to the user's search config.

Delegates the schema parsing to applypilot.locfilter.
"""
if search_cfg is None:
search_cfg = config.load_search_config()
accept = search_cfg.get("location_accept", [])
reject = search_cfg.get("location_reject_non_remote", [])
return accept, reject


def _location_ok(location: str | None, accept: list[str], reject: list[str]) -> bool:
"""Check if a job location passes the user's location filter."""
if not location:
return True
loc = location.lower()
if any(r in loc for r in ("remote", "anywhere", "work from home", "wfh", "distributed")):
return True
for r in reject:
if r.lower() in loc:
return False
for a in accept:
if a.lower() in loc:
return True
return False
return load_location_filter(search_cfg)


# -- Site configuration from YAML --------------------------------------------
Expand Down Expand Up @@ -107,10 +93,13 @@ def _store_jobs_filtered(
filtered += 1
continue
try:
# Prefer a parsed company; fall back to the site name (direct career
# sites are themselves the employer).
company = job.get("company") or site
conn.execute(
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), job.get("salary"), job.get("description"),
"INSERT INTO jobs (url, title, company, salary, description, location, site, strategy, discovered_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), company, job.get("salary"), job.get("description"),
job.get("location"), site, strategy, now),
)
new += 1
Expand Down
39 changes: 10 additions & 29 deletions src/applypilot/discovery/workday.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from applypilot import config
from applypilot.config import CONFIG_DIR
from applypilot.database import get_connection, init_db
from applypilot.locfilter import load_location_filter, location_ok as _location_ok

log = logging.getLogger(__name__)

Expand All @@ -41,34 +42,13 @@ def load_employers() -> dict:
# -- Location filtering from search config -----------------------------------

def _load_location_filter(search_cfg: dict | None = None):
"""Load location accept/reject lists from search config."""
"""Load location accept/reject lists, defaulting to the user's search config.

Delegates the schema parsing to applypilot.locfilter.
"""
if search_cfg is None:
search_cfg = config.load_search_config()

accept = search_cfg.get("location_accept", [])
reject = search_cfg.get("location_reject_non_remote", [])
return accept, reject


def _location_ok(location: str | None, accept: list[str], reject: list[str]) -> bool:
"""Check if a job location passes the user's location filter."""
if not location:
return True

loc = location.lower()

if any(r in loc for r in ("remote", "anywhere", "work from home", "wfh", "distributed")):
return True

for r in reject:
if r.lower() in loc:
return False

for a in accept:
if a.lower() in loc:
return True

return False
return load_location_filter(search_cfg)


# -- HTML stripper -----------------------------------------------------------
Expand Down Expand Up @@ -322,14 +302,15 @@ def store_results(conn: sqlite3.Connection, jobs: list[dict], employers: dict) -
detail_error = job.get("detail_error")

site = job.get("employer_name", "Corporate")
company = job.get("employer_name") # Workday portals are the employer
strategy = "workday_api"

try:
conn.execute(
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, "
"INSERT INTO jobs (url, title, company, salary, description, location, site, strategy, "
"discovered_at, full_description, application_url, detail_scraped_at, detail_error) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), None, short_desc, job.get("location"),
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(url, job.get("title"), company, None, short_desc, job.get("location"),
site, strategy, now, full_description, url, detail_scraped_at, detail_error),
)
new += 1
Expand Down
54 changes: 54 additions & 0 deletions src/applypilot/locfilter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Canonical job-location filtering shared by all discovery sources.

Historically each discovery module carried its own copy of this logic and read
config keys (``location_accept`` / ``location_reject_non_remote``) that nothing
ever wrote -- the shipped config uses ``location.accept_patterns`` /
``location.reject_patterns``. With empty lists the old code rejected every
non-remote job, silently discarding almost all results. This module reads both
schemas and treats an empty accept list as "accept everything not rejected".
"""
from __future__ import annotations

_REMOTE_MARKERS = ("remote", "anywhere", "work from home", "wfh", "distributed")


def load_location_filter(search_cfg: dict | None) -> tuple[list[str], list[str]]:
"""Read accept/reject location patterns from a search config dict.

Supports the current ``location: {accept_patterns, reject_patterns}`` schema
and the legacy flat ``location_accept`` / ``location_reject_non_remote`` keys.
"""
cfg = search_cfg or {}
loc = cfg.get("location", {}) or {}
accept = loc.get("accept_patterns") or cfg.get("location_accept", []) or []
reject = loc.get("reject_patterns") or cfg.get("location_reject_non_remote", []) or []
return accept, reject


def location_ok(location: str | None, accept: list[str], reject: list[str]) -> bool:
"""Decide whether a job location passes the filter.

Remote locations are always accepted. A reject-pattern match always fails.
Empty accept list = accept everything not explicitly rejected. A non-empty
accept list is exclusive: a non-remote location must match it to pass.
"""
if not location:
return True # unknown location -- keep it, let the scorer decide

loc = location.lower()

if any(marker in loc for marker in _REMOTE_MARKERS):
return True

for r in reject:
if r.lower() in loc:
return False

if not accept:
return True # nothing explicitly rejected and no accept list to enforce

for a in accept:
if a.lower() in loc:
return True

return False
2 changes: 1 addition & 1 deletion src/applypilot/scoring/cover_letter.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def generate_cover_letter(
"""
job_text = (
f"TITLE: {job['title']}\n"
f"COMPANY: {job['site']}\n"
f"COMPANY: {job.get('company') or job['site']}\n"
f"LOCATION: {job.get('location', 'N/A')}\n\n"
f"DESCRIPTION:\n{(job.get('full_description') or '')[:6000]}"
)
Expand Down
Loading