diff --git a/jobsource/careers/cascade.py b/jobsource/careers/cascade.py index 8b2bad9..c9e0601 100644 --- a/jobsource/careers/cascade.py +++ b/jobsource/careers/cascade.py @@ -1,12 +1,13 @@ """find_careers_page(): orchestrate the Stage 2 tier cascade. Cascade order (return early on first success): - 1. ATS detection → ats.detect_and_fetch() confidence 0.95 - 2. URL patterns → heuristics.probe_url_patterns() 0.80 - 3. Homepage scan → heuristics.scan_homepage_links() 0.60 - 4. Sitemap → heuristics.parse_sitemap() 0.50 - 5. Cheap-LLM → classify_llm (stub, not implemented in this phase) - 6. Browser agent → agent_fallback (stub, not implemented in this phase) + 1. ATS detection → ats.detect_and_fetch() confidence 0.95 + 1b. ATS slug-guess → ats.recover_via_slug_guess() confidence 0.90 + 2. URL patterns → heuristics.probe_url_patterns() confidence 0.80 + 3. Homepage scan → heuristics.scan_homepage_links() confidence 0.60 + 4. Sitemap → heuristics.parse_sitemap() confidence 0.50 + 5. Cheap-LLM → classify_llm.classify_careers_link() confidence 0.55 + 6. Browser agent → agent_fallback (not implemented in this phase) Returns a CareersResult with the URL, confidence, method string, and — when the ATS tier resolves — the first open-position URL for free (Stage-3 shortcut). @@ -24,6 +25,7 @@ from pydantic import BaseModel from ..http import build_client, request_with_retries from . import ats as _ats +from . import classify_llm as _classify_llm from . import heuristics as _heuristics logger = logging.getLogger(__name__) @@ -39,7 +41,7 @@ class CareersResult(BaseModel): careers_url: str | None = None confidence: float = 0.0 - # method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "none" + # method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "llm_classify" | "none" method: str = "none" ats_name: str | None = None # Free Stage-3 shortcut: populated when ATS tier resolves (first open job URL). @@ -145,8 +147,23 @@ def find_careers_page( except Exception as exc: logger.warning("cascade(%s): sitemap tier error: %s", website, exc) - # All deterministic tiers missed. - logger.info("cascade(%s): all deterministic tiers missed", website) + # ------------------------------------------------------------------ + # Tier 5 — Cheap-LLM careers-link classification + # ------------------------------------------------------------------ + try: + if homepage_html: + anchors = _classify_llm.extract_anchors(homepage_html, website) + res = _classify_llm.classify_careers_link(anchors) + if res: + logger.info( + "cascade(%s): resolved via llm_classify careers_url=%s confidence=%.2f", + website, res.careers_url, res.confidence, + ) + return _finalize(res.careers_url, "llm_classify", 0.55, website, client) + except Exception as exc: + logger.warning("cascade(%s): llm_classify tier error: %s", website, exc) + + logger.info("cascade(%s): all tiers missed", website) return CareersResult() finally: diff --git a/jobsource/careers/classify_llm.py b/jobsource/careers/classify_llm.py index e3a1b63..db78c75 100644 --- a/jobsource/careers/classify_llm.py +++ b/jobsource/careers/classify_llm.py @@ -1,13 +1,235 @@ """Cheap-LLM link classification for careers page and job links (Stage 2, tier 5 / Stage 3, tier 4). -Scaffold stub -- not implemented yet. +Two typed classification functions backed by Pydantic AI: + 1. classify_careers_link(anchors) -> CareerLinkResult | None + Given a list of (url, text) pairs from a page, pick the single careers/jobs page URL. + 2. classify_job_link(anchors) -> JobLinkResult | None + Given a list of (url, text) pairs from a careers page, pick one specific open-position URL. + +Both return None and the cascade falls through gracefully when: + - classifier_model or llm_api_key is a placeholder (LLM not configured), + - the anchor list is empty, + - the LLM call fails for any reason, + - the model abstains (index -1) or returns an out-of-range index. + +pydantic_ai is imported lazily inside _build_link_agent so a missing package or +unconfigured provider never breaks module import (graceful-degradation rule #8). + +extract_anchors() is also exported here so cascade.py and extract.py can share +the same anchor-parsing logic without a separate utility module. """ -# TODO (Stage 2 tier 5 / Stage 3 tier 4): implement per CLAUDE.md "Cheap-LLM classification". -# Uses Pydantic AI (model-agnostic) with the `classifier_model` from config. -# Two typed tasks: -# 1. classify_careers_link(anchors: list[Anchor]) -> CareerLinkResult -# Given extracted tags from a page, pick the careers/jobs page URL. -# 2. classify_job_link(anchors: list[Anchor]) -> JobLinkResult -# Given extracted tags from a careers page, pick one open-position URL. -# Both return a typed Pydantic result including the chosen URL and confidence. -# Graceful degradation: if llm_api_key is placeholder or call fails, return None. +from __future__ import annotations + +import logging +from urllib.parse import urljoin + +from bs4 import BeautifulSoup +from pydantic import BaseModel, Field + +from ..config import get_settings + +logger = logging.getLogger(__name__) + +# Maximum number of anchors forwarded to the LLM (token-budget guard). +_MAX_ANCHORS = 60 +# Maximum anchor text length, in characters, before truncation. +_MAX_ANCHOR_TEXT = 80 + + +# --------------------------------------------------------------------------- +# Data models +# --------------------------------------------------------------------------- + + +class Anchor(BaseModel): + """One hyperlink extracted from a page — the LLM's input row.""" + + url: str + text: str = "" + + +class _LinkChoice(BaseModel): + """LLM structured output: 0-based index into the candidate list + confidence.""" + + index: int = Field( + description=( + "0-based index of the best link in the candidate list. " + "Use -1 if no link qualifies." + ) + ) + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + + +class CareerLinkResult(BaseModel): + """Public result of classify_careers_link().""" + + careers_url: str + confidence: float = 0.0 + + +class JobLinkResult(BaseModel): + """Public result of classify_job_link().""" + + job_url: str + confidence: float = 0.0 + + +# --------------------------------------------------------------------------- +# System-level prompt instructions for each classification task +# --------------------------------------------------------------------------- + +_CAREERS_INSTRUCTIONS = ( + "You are a link classifier for a job-sourcing pipeline. " + "You receive a numbered list of hyperlinks extracted from a company website. " + "Identify the single link that leads to the company's main careers or jobs page " + "(the page that lists open positions). " + "Respond with the 0-based index of the best match and your confidence (0.0–1.0). " + "If none of the links leads to a careers or jobs page, respond with index -1." +) + +_JOB_INSTRUCTIONS = ( + "You are a link classifier for a job-sourcing pipeline. " + "You receive a numbered list of hyperlinks extracted from a careers/jobs page. " + "Identify the single link that leads directly to one specific open job-posting detail page " + "(a page for exactly one position — not a category page, a filter, or a job listing index). " + "Respond with the 0-based index of the best match and your confidence (0.0–1.0). " + "If none of the links qualifies as a single-job detail page, respond with index -1." +) + + +# --------------------------------------------------------------------------- +# Shared anchor extractor (used by cascade.py Tier 5 and extract.py Tiers 3-4) +# --------------------------------------------------------------------------- + + +def extract_anchors(html: str, base_url: str) -> list[Anchor]: + """Extract deduplicated absolute hyperlinks from HTML. + + Skips mailto:, tel:, # (fragment-only), and javascript: hrefs. + Converts relative hrefs to absolute using base_url. + Returns only http(s) links in first-seen order, URL-deduplicated. + Truncates anchor text to _MAX_ANCHOR_TEXT characters. + """ + if not html: + return [] + soup = BeautifulSoup(html, "lxml") + seen_urls: set[str] = set() + result: list[Anchor] = [] + for tag in soup.find_all("a", href=True): + href: str = tag["href"].strip() + if not href or href.startswith(("mailto:", "tel:", "#", "javascript:")): + continue + full_url = urljoin(base_url, href) + if not full_url.startswith(("http://", "https://")): + continue + if full_url in seen_urls: + continue + seen_urls.add(full_url) + text = tag.get_text(separator=" ", strip=True)[:_MAX_ANCHOR_TEXT] + result.append(Anchor(url=full_url, text=text)) + return result + + +# --------------------------------------------------------------------------- +# LLM availability gate +# --------------------------------------------------------------------------- + + +def _llm_unavailable() -> bool: + """Return True when the LLM is not configured (placeholder values in settings).""" + s = get_settings() + return ( + s.classifier_model.startswith("PLACEHOLDER") + or s.llm_api_key.startswith("PLACEHOLDER") + ) + + +# --------------------------------------------------------------------------- +# Agent builder — extracted as a named function to serve as the test seam. +# Monkeypatch this function in tests to avoid real LLM calls. +# --------------------------------------------------------------------------- + + +def _build_link_agent(instructions: str): # type: ignore[return] + """Construct a Pydantic AI Agent for link classification. + + pydantic_ai is imported lazily here so a missing package never crashes + module import. This function is the primary test seam — monkeypatch it + to inject a FakeAgent without touching the real Pydantic AI machinery. + """ + from pydantic_ai import Agent # lazy import + + return Agent( + get_settings().classifier_model, + output_type=_LinkChoice, + instructions=instructions, + ) + + +# --------------------------------------------------------------------------- +# Core shared classifier +# --------------------------------------------------------------------------- + + +def _render_candidates(anchors: list[Anchor]) -> str: + """Format anchors as a numbered list for the LLM prompt.""" + lines = [f"[{i}] {a.url} | {a.text}" for i, a in enumerate(anchors)] + return "\n".join(lines) + + +def _choose_link( + anchors: list[Anchor], + *, + instructions: str, + label: str, +) -> "_LinkChoice | None": + """Run the LLM classifier on anchors; return a _LinkChoice or None. + + Returns None when the LLM is unconfigured, the list is empty, the model + abstains (index -1), the index is out of range, or any exception occurs. + """ + if not anchors or _llm_unavailable(): + return None + + candidates = anchors[:_MAX_ANCHORS] + try: + agent = _build_link_agent(instructions) + prompt = _render_candidates(candidates) + out = agent.run_sync(prompt).output + if isinstance(out, _LinkChoice) and 0 <= out.index < len(candidates): + return out + return None # abstain (index -1) or out-of-range + except Exception as exc: + logger.warning("classify_llm(%s): tier error: %s", label, exc) + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def classify_careers_link(anchors: list[Anchor]) -> CareerLinkResult | None: + """Pick the careers/jobs page URL from a list of extracted anchors (Stage 2, tier 5). + + Returns a CareerLinkResult on success, or None on any no-op / failure. + Never raises. + """ + c = _choose_link(anchors, instructions=_CAREERS_INSTRUCTIONS, label="careers") + if c is None: + return None + candidates = anchors[:_MAX_ANCHORS] + return CareerLinkResult(careers_url=candidates[c.index].url, confidence=c.confidence) + + +def classify_job_link(anchors: list[Anchor]) -> JobLinkResult | None: + """Pick one specific open-position URL from a careers-page anchor list (Stage 3, tier 4). + + Returns a JobLinkResult on success, or None on any no-op / failure. + Never raises. + """ + c = _choose_link(anchors, instructions=_JOB_INSTRUCTIONS, label="job") + if c is None: + return None + candidates = anchors[:_MAX_ANCHORS] + return JobLinkResult(job_url=candidates[c.index].url, confidence=c.confidence) diff --git a/jobsource/extract.py b/jobsource/extract.py index f55f360..a6f7c84 100644 --- a/jobsource/extract.py +++ b/jobsource/extract.py @@ -1,12 +1,214 @@ """Extract one open position URL from a careers page (Stage 3). -Scaffold stub -- not implemented yet. +Cascade order (return early on first hit): + 1. ATS JSON (URL) — detect ATS board coordinates in the careers URL itself; + call the board's public JSON API for the first posting URL. + 1b. ATS JSON (HTML) — fetch the careers page; detect ATS board in its HTML; + call the board API. Handles inline JS embeds. + 2. JSON-LD — scan + + """ + assert _jsonld_job_url(html) == "https://acme.com/jobs/99" + + def test_invalid_json_block_skipped(self): + html = """ + + + + + """ + assert _jsonld_job_url(html) == "https://ok.com/job/1" + + def test_no_job_posting_returns_none(self): + html = """ + + + + """ + assert _jsonld_job_url(html) is None + + def test_no_script_returns_none(self): + assert _jsonld_job_url("No scripts") is None + + +# --------------------------------------------------------------------------- +# extract_open_position — tier integration (network-free) +# --------------------------------------------------------------------------- + + +class TestExtractOpenPositionTiers: + def _patch_base(self, monkeypatch, *, html: str | None = None) -> None: + """Patch ATS detect functions to miss and HTTP fetch to return html.""" + monkeypatch.setattr( + "jobsource.extract._ats.detect_ats_in_url", + lambda url: None, + ) + monkeypatch.setattr( + "jobsource.extract._ats.detect_ats_in_html", + lambda html: None, + ) + if html is not None: + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: FakeResponse(200, html), + ) + + # -- Tier 1: ATS from URL ------------------------------------------------ + + def test_ats_from_url_returns_first_job(self, monkeypatch): + board = ATSBoard( + ats_name="greenhouse", + slug="acme", + careers_url="https://boards.greenhouse.io/acme", + ) + monkeypatch.setattr( + "jobsource.extract._ats.detect_ats_in_url", + lambda url: board, + ) + monkeypatch.setattr( + "jobsource.extract._ats._FETCH_DISPATCH", + {"greenhouse": lambda b, c: ATSFetch(first_url="https://boards.greenhouse.io/acme/jobs/1", job_count=5)}, + ) + url, method = extract_open_position( + "https://boards.greenhouse.io/acme", client=FakeClient() + ) + assert url == "https://boards.greenhouse.io/acme/jobs/1" + assert method == "ats:greenhouse" + + def test_ats_from_url_with_no_jobs_falls_through(self, monkeypatch): + board = ATSBoard( + ats_name="lever", + slug="acme", + careers_url="https://jobs.lever.co/acme", + ) + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: board) + monkeypatch.setattr( + "jobsource.extract._ats._FETCH_DISPATCH", + {"lever": lambda b, c: ATSFetch(first_url=None, job_count=0)}, + ) + monkeypatch.setattr( + "jobsource.extract._ats.detect_ats_in_html", lambda html: None + ) + # Page has no JSON-LD, no job-path anchors, LLM returns None. + # Use a neutral href that won't trigger the job-path pattern. + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: FakeResponse(200, "Team"), + ) + monkeypatch.setattr("jobsource.extract.classify_job_link", lambda anchors: None) + url, method = extract_open_position("https://acme.example.com/careers", client=FakeClient()) + assert url is None + assert method == "none" + + # -- Tier 1b: ATS from page HTML ---------------------------------------- + + def test_ats_from_html_returns_first_job(self, monkeypatch): + gh_board = ATSBoard( + ats_name="greenhouse", + slug="vercel", + careers_url="https://boards.greenhouse.io/vercel", + ) + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None) + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_html", lambda html: gh_board) + monkeypatch.setattr( + "jobsource.extract._ats._FETCH_DISPATCH", + {"greenhouse": lambda b, c: ATSFetch(first_url="https://boards.greenhouse.io/vercel/jobs/42", job_count=73)}, + ) + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: FakeResponse(200, ''), + ) + url, method = extract_open_position("https://vercel.com/careers", client=FakeClient()) + assert url == "https://boards.greenhouse.io/vercel/jobs/42" + assert method == "ats:greenhouse" + + # -- Tier 2: JSON-LD ---------------------------------------------------- + + def test_jsonld_returns_job_url(self, monkeypatch): + html = """ + + + + """ + self._patch_base(monkeypatch, html=html) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url == "https://acme.com/jobs/77" + assert method == "json_ld" + + # -- Tier 3: job-like anchor -------------------------------------------- + + def test_job_anchor_matched_by_pattern(self, monkeypatch): + html = """ + + About + Software Engineer + + """ + self._patch_base(monkeypatch, html=html) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url == "https://acme.com/jobs/software-engineer-123" + assert method == "anchor" + + def test_opening_anchor_matched(self, monkeypatch): + html = 'Open Role' + self._patch_base(monkeypatch, html=html) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url == "https://acme.com/openings/42" + assert method == "anchor" + + # -- Tier 4: LLM -------------------------------------------------------- + + def test_llm_classify_fires_when_no_job_anchor(self, monkeypatch): + html = 'AboutTeam' + self._patch_base(monkeypatch, html=html) + monkeypatch.setattr( + "jobsource.extract.classify_job_link", + lambda anchors: JobLinkResult(job_url="https://acme.com/about", confidence=0.60), + ) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url == "https://acme.com/about" + assert method == "llm_classify" + + def test_llm_returns_none_falls_through_to_none(self, monkeypatch): + html = 'About' + self._patch_base(monkeypatch, html=html) + monkeypatch.setattr( + "jobsource.extract.classify_job_link", + lambda anchors: None, + ) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url is None + assert method == "none" + + # -- Page fetch failure ------------------------------------------------- + + def test_fetch_failure_returns_none(self, monkeypatch): + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None) + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("timeout")), + ) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url is None + assert method == "none" + + def test_non_200_fetch_returns_none(self, monkeypatch): + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None) + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: FakeResponse(404, ""), + ) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url is None + assert method == "none" + + # -- All tiers miss ------------------------------------------------------ + + def test_all_miss_returns_none_none(self, monkeypatch): + html = 'About' + self._patch_base(monkeypatch, html=html) + monkeypatch.setattr("jobsource.extract.classify_job_link", lambda anchors: None) + url, method = extract_open_position("https://acme.com/careers", client=FakeClient()) + assert url is None + assert method == "none" + + # -- ATS tier error falls through --------------------------------------- + + def test_ats_fetch_error_falls_through(self, monkeypatch): + board = ATSBoard(ats_name="ashby", slug="acme", careers_url="https://jobs.ashbyhq.com/acme") + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: board) + monkeypatch.setattr( + "jobsource.extract._ats._FETCH_DISPATCH", + {"ashby": lambda b, c: (_ for _ in ()).throw(RuntimeError("api down"))}, + ) + monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_html", lambda html: None) + html = 'Engineer' + monkeypatch.setattr( + "jobsource.extract.request_with_retries", + lambda *a, **kw: FakeResponse(200, html), + ) + url, method = extract_open_position("https://jobs.ashbyhq.com/acme", client=FakeClient()) + # urljoin("https://jobs.ashbyhq.com/acme", "/jobs/42") → root-relative + assert url == "https://jobs.ashbyhq.com/jobs/42" + assert method == "anchor"