diff --git a/CHANGELOG.md b/CHANGELOG.md index 5682b2701..7e3416aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/applypilot/apply/launcher.py b/src/applypilot/apply/launcher.py index 341a11a36..92aed958b 100644 --- a/src/applypilot/apply/launcher.py +++ b/src/applypilot/apply/launcher.py @@ -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 ?) @@ -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 @@ -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', '')}") diff --git a/src/applypilot/apply/prompt.py b/src/applypilot/apply/prompt.py index 37c3790a1..98b1d7478 100644 --- a/src/applypilot/apply/prompt.py +++ b/src/applypilot/apply/prompt.py @@ -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 == diff --git a/src/applypilot/database.py b/src/applypilot/database.py index a1779c02a..ea08f8848 100644 --- a/src/applypilot/database.py +++ b/src/applypilot/database.py @@ -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, @@ -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", @@ -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 diff --git a/src/applypilot/discovery/jobspy.py b/src/applypilot/discovery/jobspy.py index b5e54ff44..6456b64b6 100644 --- a/src/applypilot/discovery/jobspy.py +++ b/src/applypilot/discovery/jobspy.py @@ -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__) @@ -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) --------------------------------- @@ -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 diff --git a/src/applypilot/discovery/smartextract.py b/src/applypilot/discovery/smartextract.py index cf49a9a2d..8dfdd45d1 100644 --- a/src/applypilot/discovery/smartextract.py +++ b/src/applypilot/discovery/smartextract.py @@ -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__) @@ -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 -------------------------------------------- @@ -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 diff --git a/src/applypilot/discovery/workday.py b/src/applypilot/discovery/workday.py index cef69fe45..d9680d15a 100644 --- a/src/applypilot/discovery/workday.py +++ b/src/applypilot/discovery/workday.py @@ -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__) @@ -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 ----------------------------------------------------------- @@ -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 diff --git a/src/applypilot/locfilter.py b/src/applypilot/locfilter.py new file mode 100644 index 000000000..dbdd94f21 --- /dev/null +++ b/src/applypilot/locfilter.py @@ -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 diff --git a/src/applypilot/scoring/cover_letter.py b/src/applypilot/scoring/cover_letter.py index c16cdd5f7..c5601d82e 100644 --- a/src/applypilot/scoring/cover_letter.py +++ b/src/applypilot/scoring/cover_letter.py @@ -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]}" ) diff --git a/src/applypilot/scoring/scorer.py b/src/applypilot/scoring/scorer.py index 97692d5f7..d13a17393 100644 --- a/src/applypilot/scoring/scorer.py +++ b/src/applypilot/scoring/scorer.py @@ -48,24 +48,25 @@ def _parse_score_response(response: str) -> dict: response: Raw LLM response text. Returns: - {"score": int, "keywords": str, "reasoning": str} + {"score": int | None, "keywords": str, "reasoning": str}. + score is None when no parseable SCORE line was found (a parse failure, + which must NOT be persisted as a permanent fit_score of 0). """ - score = 0 + score = None keywords = "" reasoning = response - for line in response.split("\n"): - line = line.strip() - if line.startswith("SCORE:"): - try: - score = int(re.search(r"\d+", line).group()) - score = max(1, min(10, score)) - except (AttributeError, ValueError): - score = 0 - elif line.startswith("KEYWORDS:"): - keywords = line.replace("KEYWORDS:", "").strip() - elif line.startswith("REASONING:"): - reasoning = line.replace("REASONING:", "").strip() + for raw in response.split("\n"): + # Tolerate markdown decoration like "**SCORE:** 8" or "## Score: 7/10". + line = raw.strip().lstrip("#*").strip() + low = line.lower() + if low.startswith("score"): + m = re.search(r"\d+", line) + score = max(1, min(10, int(m.group()))) if m else None + elif low.startswith("keywords"): + keywords = line.split(":", 1)[-1].strip().rstrip("*").strip() + elif low.startswith("reasoning"): + reasoning = line.split(":", 1)[-1].strip().rstrip("*").strip() return {"score": score, "keywords": keywords, "reasoning": reasoning} @@ -82,7 +83,7 @@ def score_job(resume_text: str, job: dict) -> dict: """ 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]}" ) @@ -98,7 +99,8 @@ def score_job(resume_text: str, job: dict) -> dict: return _parse_score_response(response) except Exception as e: log.error("LLM error scoring job '%s': %s", job.get("title", "?"), e) - return {"score": 0, "keywords": "", "reasoning": f"LLM error: {e}"} + # score=None (not 0) so the job stays pending and is retried next run. + return {"score": None, "keywords": "", "reasoning": f"LLM error: {e}"} def run_scoring(limit: int = 0, rescore: bool = False) -> dict: @@ -142,27 +144,35 @@ def run_scoring(limit: int = 0, rescore: bool = False) -> dict: result["url"] = job["url"] completed += 1 - if result["score"] == 0: - errors += 1 - results.append(result) - log.info( - "[%d/%d] score=%d %s", - completed, len(jobs), result["score"], job.get("title", "?")[:60], - ) - - # Write scores to DB - now = datetime.now(timezone.utc).isoformat() - for r in results: + if result["score"] is None: + # Parse/LLM failure -- leave fit_score NULL so it's retried, don't + # burn the result by persisting a permanent 0. + errors += 1 + log.warning( + "[%d/%d] score failed (left pending) %s", + completed, len(jobs), job.get("title", "?")[:60], + ) + continue + + # Commit each score as it lands so an interrupt doesn't discard the run. + now = datetime.now(timezone.utc).isoformat() conn.execute( "UPDATE jobs SET fit_score = ?, score_reasoning = ?, scored_at = ? WHERE url = ?", - (r["score"], f"{r['keywords']}\n{r['reasoning']}", now, r["url"]), + (result["score"], f"{result['keywords']}\n{result['reasoning']}", now, result["url"]), + ) + conn.commit() + + log.info( + "[%d/%d] score=%s %s", + completed, len(jobs), result["score"], job.get("title", "?")[:60], ) - conn.commit() + scored = len(results) - errors elapsed = time.time() - t0 - log.info("Done: %d scored in %.1fs (%.1f jobs/sec)", len(results), elapsed, len(results) / elapsed if elapsed > 0 else 0) + log.info("Done: %d scored, %d failed in %.1fs (%.1f jobs/sec)", + scored, errors, elapsed, len(results) / elapsed if elapsed > 0 else 0) # Score distribution dist = conn.execute(""" @@ -173,7 +183,7 @@ def run_scoring(limit: int = 0, rescore: bool = False) -> dict: distribution = [(row[0], row[1]) for row in dist] return { - "scored": len(results), + "scored": scored, "errors": errors, "elapsed": elapsed, "distribution": distribution, diff --git a/src/applypilot/scoring/tailor.py b/src/applypilot/scoring/tailor.py index 352fb5ff9..5f5e29474 100644 --- a/src/applypilot/scoring/tailor.py +++ b/src/applypilot/scoring/tailor.py @@ -371,7 +371,7 @@ def tailor_resume( """ 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]}" ) @@ -503,7 +503,7 @@ def run_tailoring(min_score: int = 7, limit: int = 20, job_path = TAILORED_DIR / f"{prefix}_JOB.txt" job_desc = ( 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" f"Score: {job.get('fit_score', 'N/A')}\n" f"URL: {job['url']}\n\n" diff --git a/tests/test_company_column.py b/tests/test_company_column.py new file mode 100644 index 000000000..93a1172c4 --- /dev/null +++ b/tests/test_company_column.py @@ -0,0 +1,38 @@ +"""F7: the real company name is stored and migrated, not discarded.""" +import sqlite3 + +import pandas as pd + +import applypilot.database as db +from applypilot.discovery.jobspy import store_jobspy_results + + +def test_store_jobspy_persists_company(tmp_path, monkeypatch): + monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db") + conn = db.init_db() + df = pd.DataFrame([{ + "job_url": "https://example.com/job1", + "title": "Engineer", + "company": "Acme Corp", + "location": "Remote", + "site": "linkedin", + }]) + store_jobspy_results(conn, df, "linkedin") + row = conn.execute("SELECT company FROM jobs WHERE url = ?", + ("https://example.com/job1",)).fetchone() + assert row["company"] == "Acme Corp" + + +def test_ensure_columns_adds_company_to_old_db(tmp_path, monkeypatch): + path = tmp_path / "old.db" + # Simulate a pre-F7 schema with no company column. + old = sqlite3.connect(path) + old.execute("CREATE TABLE jobs (url TEXT PRIMARY KEY, title TEXT, site TEXT)") + old.commit() + old.close() + + monkeypatch.setattr(db, "DB_PATH", path) + conn = db.get_connection(path) + db.ensure_columns(conn) + cols = {r[1] for r in conn.execute("PRAGMA table_info(jobs)").fetchall()} + assert "company" in cols diff --git a/tests/test_locfilter.py b/tests/test_locfilter.py new file mode 100644 index 000000000..c5a923bec --- /dev/null +++ b/tests/test_locfilter.py @@ -0,0 +1,43 @@ +"""F6: the location filter must not discard nearly every job by default.""" +from applypilot.locfilter import load_location_filter, location_ok + + +def test_empty_accept_keeps_non_remote(): + assert location_ok("San Francisco, CA", [], []) is True + + +def test_reject_pattern_still_blocks_with_empty_accept(): + assert location_ok("Pune, India", [], ["india"]) is False + + +def test_explicit_accept_list_is_exclusive(): + assert location_ok("Oakland", ["Bay Area"], []) is False + assert location_ok("Bay Area office", ["Bay Area"], []) is True + + +def test_remote_always_passes(): + assert location_ok("Remote - US", ["Bay Area"], ["india"]) is True + + +def test_none_location_passes(): + assert location_ok(None, ["Bay Area"], []) is True + + +def test_loads_current_schema(): + cfg = {"location": {"accept_patterns": ["San Francisco", "Remote"], + "reject_patterns": ["India"]}} + accept, reject = load_location_filter(cfg) + assert accept == ["San Francisco", "Remote"] + assert reject == ["India"] + + +def test_loads_legacy_schema(): + cfg = {"location_accept": ["NYC"], "location_reject_non_remote": ["China"]} + accept, reject = load_location_filter(cfg) + assert accept == ["NYC"] + assert reject == ["China"] + + +def test_empty_config_yields_empty_lists(): + accept, reject = load_location_filter({}) + assert accept == [] and reject == [] diff --git a/tests/test_scoring.py b/tests/test_scoring.py new file mode 100644 index 000000000..4e92b1039 --- /dev/null +++ b/tests/test_scoring.py @@ -0,0 +1,70 @@ +"""F8: robust score parsing + never persist permanent zero on failure.""" +import applypilot.database as db +import applypilot.scoring.scorer as scorer +from applypilot.scoring.scorer import _parse_score_response, run_scoring + + +def test_markdown_score_line(): + assert _parse_score_response("**SCORE:** 8\nKEYWORDS: a\nREASONING: b")["score"] == 8 + + +def test_score_with_slash(): + assert _parse_score_response("Score: 7/10")["score"] == 7 + + +def test_no_score_line_is_none(): + assert _parse_score_response("the model rambled with no verdict")["score"] is None + + +def test_unparseable_score_is_none(): + assert _parse_score_response("SCORE: N/A")["score"] is None + + +def test_plain_score(): + assert _parse_score_response("SCORE: 10")["score"] == 10 + + +def _seed(conn, url): + conn.execute( + "INSERT INTO jobs (url, title, full_description) VALUES (?,?,?)", + (url, "Engineer", "a long enough description " * 20), + ) + conn.commit() + + +def test_failed_score_left_pending(tmp_path, monkeypatch): + monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db") + monkeypatch.setattr(scorer, "RESUME_PATH", tmp_path / "resume.txt") + (tmp_path / "resume.txt").write_text("my resume") + conn = db.init_db() + _seed(conn, "https://example.com/ok") + _seed(conn, "https://example.com/fail") + + def fake_score(resume, job): + if job["url"].endswith("fail"): + return {"score": None, "keywords": "", "reasoning": "boom"} + return {"score": 9, "keywords": "k", "reasoning": "r"} + + monkeypatch.setattr(scorer, "score_job", fake_score) + out = run_scoring() + + assert out["scored"] == 1 and out["errors"] == 1 + ok = conn.execute("SELECT fit_score FROM jobs WHERE url=?", + ("https://example.com/ok",)).fetchone() + bad = conn.execute("SELECT fit_score FROM jobs WHERE url=?", + ("https://example.com/fail",)).fetchone() + assert ok["fit_score"] == 9 # committed + assert bad["fit_score"] is None # left pending, not a permanent 0 + + +def test_pending_score_stage_still_includes_failed(tmp_path, monkeypatch): + monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db") + monkeypatch.setattr(scorer, "RESUME_PATH", tmp_path / "resume.txt") + (tmp_path / "resume.txt").write_text("my resume") + conn = db.init_db() + _seed(conn, "https://example.com/fail") + monkeypatch.setattr(scorer, "score_job", + lambda r, j: {"score": None, "keywords": "", "reasoning": "x"}) + run_scoring() + pending = db.get_jobs_by_stage(conn=conn, stage="pending_score", limit=0) + assert any(j["url"] == "https://example.com/fail" for j in pending)