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
+