careers-llm-extract
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 <a> tags from a page, pick the careers/jobs page URL.
|
||||
# 2. classify_job_link(anchors: list[Anchor]) -> JobLinkResult
|
||||
# Given extracted <a> 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)
|
||||
|
||||
@@ -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 <script type="application/ld+json"> for a
|
||||
JobPosting node with a `url` field.
|
||||
3. Job-like anchors — first <a> whose href matches /job|/position|/opening|/vacanc.
|
||||
4. Cheap-LLM — classify_job_link() from classify_llm; no-ops gracefully
|
||||
when the LLM is not configured.
|
||||
5. Miss — return (None, "none").
|
||||
|
||||
Returns (url: str | None, method: str). Never raises — any tier failure logs
|
||||
and falls through. method is one of:
|
||||
"ats:{name}" | "json_ld" | "anchor" | "llm_classify" | "none"
|
||||
"""
|
||||
# TODO (Stage 3): implement per CLAUDE.md "Stage 3 — Extract one open position (return on first hit)".
|
||||
# Cascade order (return early on first hit):
|
||||
# 1. ATS JSON — if ATS is already known from Stage 2, return first posting URL directly.
|
||||
# 2. JobPosting JSON-LD — parse application/ld+json for a `url` field.
|
||||
# 3. Job-like anchors — first <a> matching /job, /position, /opening, /vacancy in href.
|
||||
# 4. Cheap-LLM classification — Pydantic AI typed output (classifier_model).
|
||||
# 5. Browser-agent fallback — handled inside the fused Stage-2 agent call in agent_fallback.py.
|
||||
# Returns (url: str | None, method: str) so callers know which tier resolved it.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .careers import ats as _ats
|
||||
from .careers.classify_llm import classify_job_link, extract_anchors
|
||||
from .http import build_client, request_with_retries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches href paths that indicate a single-job detail page.
|
||||
_JOB_HREF_RE = re.compile(r"/(job|position|opening|vacanc)", re.IGNORECASE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_html(url: str, client: httpx.Client) -> str | None:
|
||||
"""Fetch page HTML; return text on success or None on any error/non-2xx."""
|
||||
try:
|
||||
resp = request_with_retries(client, "GET", url, max_retries=1)
|
||||
if resp.status_code < 400:
|
||||
return resp.text
|
||||
logger.info("extract._get_html(%s): HTTP %s", url, resp.status_code)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.info("extract._get_html(%s): fetch error: %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _jsonld_job_url(html: str) -> str | None:
|
||||
"""Scan every <script type="application/ld+json"> block for a JobPosting url."""
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
for script in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(script.get_text(strip=True))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
url = _find_job_posting_url(data)
|
||||
if url:
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _find_job_posting_url(data: object) -> str | None:
|
||||
"""Walk a JSON-LD structure (dict, list, @graph) for a JobPosting url field."""
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
result = _find_job_posting_url(item)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
# Expand @graph (JSON-LD named graph — a list of typed nodes)
|
||||
graph = data.get("@graph")
|
||||
if graph:
|
||||
result = _find_job_posting_url(graph)
|
||||
if result:
|
||||
return result
|
||||
# Check this node's @type
|
||||
type_val = data.get("@type", "")
|
||||
if isinstance(type_val, list):
|
||||
is_job = "JobPosting" in type_val
|
||||
else:
|
||||
is_job = type_val == "JobPosting"
|
||||
if is_job:
|
||||
url = data.get("url")
|
||||
if url and isinstance(url, str):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_ats_position(board: "_ats.ATSBoard", client: httpx.Client) -> str | None:
|
||||
"""Call the ATS public JSON API for *board* and return the first job URL or None."""
|
||||
fetch_fn = _ats._FETCH_DISPATCH.get(board.ats_name)
|
||||
if fetch_fn is None:
|
||||
return None
|
||||
fetch = fetch_fn(board, client) # type: ignore[operator]
|
||||
return fetch.first_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_open_position(
|
||||
careers_url: str,
|
||||
*,
|
||||
client: httpx.Client | None = None,
|
||||
) -> tuple[str | None, str]:
|
||||
"""Return (open_position_url, method) for a careers page. Never raises.
|
||||
|
||||
Tries ATS JSON → JSON-LD → job-like anchor → LLM classifier in order,
|
||||
returning on the first hit. method names which tier resolved it.
|
||||
"""
|
||||
_managed = client is None
|
||||
if _managed:
|
||||
client = build_client()
|
||||
|
||||
try:
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1 — ATS JSON from the careers URL string (no page fetch needed)
|
||||
# ------------------------------------------------------------------
|
||||
board = _ats.detect_ats_in_url(careers_url)
|
||||
if board is not None:
|
||||
try:
|
||||
pos_url = _fetch_ats_position(board, client)
|
||||
if pos_url:
|
||||
logger.info(
|
||||
"extract(%s): ats:%s from_url pos=%s",
|
||||
careers_url, board.ats_name, pos_url,
|
||||
)
|
||||
return pos_url, f"ats:{board.ats_name}"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): ats url-tier error: %s", careers_url, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fetch page HTML — shared by all remaining tiers
|
||||
# ------------------------------------------------------------------
|
||||
html = _get_html(careers_url, client)
|
||||
if html is None:
|
||||
logger.info("extract(%s): page fetch failed; skipping remaining tiers", careers_url)
|
||||
return None, "none"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1b — ATS JSON from page HTML (handles JS-embedded board pages)
|
||||
# ------------------------------------------------------------------
|
||||
html_board = _ats.detect_ats_in_html(html)
|
||||
if html_board is not None:
|
||||
try:
|
||||
pos_url2 = _fetch_ats_position(html_board, client)
|
||||
if pos_url2:
|
||||
logger.info(
|
||||
"extract(%s): ats:%s from_html pos=%s",
|
||||
careers_url, html_board.ats_name, pos_url2,
|
||||
)
|
||||
return pos_url2, f"ats:{html_board.ats_name}"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): ats html-tier error: %s", careers_url, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 2 — JobPosting JSON-LD
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
jld_url = _jsonld_job_url(html)
|
||||
if jld_url:
|
||||
logger.info("extract(%s): json_ld pos=%s", careers_url, jld_url)
|
||||
return jld_url, "json_ld"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): json_ld tier error: %s", careers_url, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 3 & 4 — Job-like anchors, then LLM (share one extraction pass)
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
anchors = extract_anchors(html, careers_url)
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): anchor extraction error: %s", careers_url, exc)
|
||||
anchors = []
|
||||
|
||||
# Tier 3 — first anchor whose URL *path* matches the job-URL pattern.
|
||||
# We search only the path component so hostnames like jobs.lever.co
|
||||
# don't trigger a false match (e.g. `//jobs` contains `/job`).
|
||||
try:
|
||||
for anchor in anchors:
|
||||
if _JOB_HREF_RE.search(urlsplit(anchor.url).path):
|
||||
logger.info("extract(%s): anchor pos=%s", careers_url, anchor.url)
|
||||
return anchor.url, "anchor"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): anchor tier error: %s", careers_url, exc)
|
||||
|
||||
# Tier 4 — cheap-LLM job-link classifier (no-ops when LLM unconfigured)
|
||||
try:
|
||||
llm_result = classify_job_link(anchors)
|
||||
if llm_result is not None:
|
||||
logger.info("extract(%s): llm_classify pos=%s", careers_url, llm_result.job_url)
|
||||
return llm_result.job_url, "llm_classify"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): llm_classify tier error: %s", careers_url, exc)
|
||||
|
||||
logger.info("extract(%s): all tiers missed", careers_url)
|
||||
return None, "none"
|
||||
|
||||
finally:
|
||||
if _managed:
|
||||
client.close()
|
||||
|
||||
207
scripts/e2e_smoke.py
Normal file
207
scripts/e2e_smoke.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python
|
||||
"""End-to-end smoke test: Stage 1→3 on a 10-job batch.
|
||||
|
||||
Usage: .venv/bin/python scripts/e2e_smoke.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Make sure we run from the repo root so imports resolve correctly.
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING, # keep jobspy/httpx chatter suppressed
|
||||
format="%(name)s: %(message)s",
|
||||
)
|
||||
# Enable our own modules at INFO so we see cascade method names.
|
||||
for _mod in ("jobsource.resolve", "jobsource.careers.cascade",
|
||||
"jobsource.careers.ats", "jobsource.careers.heuristics",
|
||||
"jobsource.careers.classify_llm", "jobsource.extract"):
|
||||
logging.getLogger(_mod).setLevel(logging.INFO)
|
||||
|
||||
from jobsource.careers.cascade import find_careers_page
|
||||
from jobsource.config import get_settings
|
||||
from jobsource.extract import extract_open_position
|
||||
from jobsource.http import build_client
|
||||
from jobsource.models import RawJob
|
||||
from jobsource.resolve import resolve_website
|
||||
from jobsource.sources.jobspy_source import JobSpySource
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-record result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
job_id: str
|
||||
company: str
|
||||
website: str | None = None
|
||||
careers_url: str | None = None
|
||||
careers_method: str | None = None
|
||||
position_url: str | None = None
|
||||
position_method: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BATCH_SIZE = 10
|
||||
SEARCH_TERM = "software engineer"
|
||||
LOCATION = "United States"
|
||||
HOURS_OLD = 72
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
print(f"\n{'='*64}")
|
||||
print(f" E2E smoke batch={BATCH_SIZE} search={SEARCH_TERM!r} hours_old={HOURS_OLD}")
|
||||
print(f"{'='*64}\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stage 1 — Ingest
|
||||
# ------------------------------------------------------------------
|
||||
print("Stage 1: ingesting…")
|
||||
source = JobSpySource()
|
||||
t0 = time.perf_counter()
|
||||
raw_jobs: list[RawJob] = source.fetch_recent_jobs(
|
||||
search_terms=[SEARCH_TERM],
|
||||
location=LOCATION,
|
||||
hours_old=HOURS_OLD,
|
||||
results_wanted=BATCH_SIZE + 10, # fetch a few extra to cover dupes
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
raw_jobs = raw_jobs[:BATCH_SIZE]
|
||||
print(f" Ingested {len(raw_jobs)} unique jobs in {elapsed:.1f}s\n")
|
||||
|
||||
if not raw_jobs:
|
||||
print("No jobs returned — aborting.")
|
||||
return
|
||||
|
||||
results: list[Result] = []
|
||||
|
||||
with build_client() as client:
|
||||
for raw in raw_jobs:
|
||||
res = Result(job_id=raw.job_id, company=raw.company)
|
||||
print(f"[{raw.job_id}] {raw.company}")
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 1b — Resolve website
|
||||
# -------------------------------------------------------
|
||||
try:
|
||||
res.website = resolve_website(
|
||||
raw.company, raw.website, client=client
|
||||
)
|
||||
except Exception as exc:
|
||||
res.error = f"resolve: {exc}"
|
||||
print(f" resolve ERROR: {exc}")
|
||||
results.append(res)
|
||||
continue
|
||||
|
||||
if not res.website:
|
||||
print(f" website: UNRESOLVED")
|
||||
results.append(res)
|
||||
continue
|
||||
print(f" website: {res.website}")
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 2 — Find careers page
|
||||
# -------------------------------------------------------
|
||||
try:
|
||||
cr = find_careers_page(
|
||||
res.website,
|
||||
company_name=raw.company,
|
||||
client=client,
|
||||
)
|
||||
res.careers_url = cr.careers_url
|
||||
res.careers_method = cr.method
|
||||
except Exception as exc:
|
||||
res.error = f"cascade: {exc}"
|
||||
print(f" careers ERROR: {exc}")
|
||||
results.append(res)
|
||||
continue
|
||||
|
||||
if not res.careers_url:
|
||||
print(f" careers: MISSED (method={cr.method})")
|
||||
results.append(res)
|
||||
continue
|
||||
print(f" careers: {res.careers_url} [{cr.method} conf={cr.confidence:.2f}]")
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 3 — Extract one open position
|
||||
# -------------------------------------------------------
|
||||
try:
|
||||
pos_url, pos_method = extract_open_position(
|
||||
res.careers_url, client=client
|
||||
)
|
||||
res.position_url = pos_url
|
||||
res.position_method = pos_method
|
||||
except Exception as exc:
|
||||
res.error = f"extract: {exc}"
|
||||
print(f" position ERROR: {exc}")
|
||||
results.append(res)
|
||||
continue
|
||||
|
||||
if res.position_url:
|
||||
print(f" position: {res.position_url} [{pos_method}]")
|
||||
else:
|
||||
print(f" position: MISSED (method={pos_method})")
|
||||
|
||||
results.append(res)
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary
|
||||
# ------------------------------------------------------------------
|
||||
n_total = len(results)
|
||||
n_website = sum(1 for r in results if r.website)
|
||||
n_careers = sum(1 for r in results if r.careers_url)
|
||||
n_position = sum(1 for r in results if r.position_url)
|
||||
n_errors = sum(1 for r in results if r.error)
|
||||
|
||||
print(f"\n{'='*64}")
|
||||
print(f" SUMMARY ({n_total} jobs processed)")
|
||||
print(f"{'='*64}")
|
||||
print(f" Stage 1 ingested {n_total:>3}")
|
||||
print(f" Stage 1b website resolved {n_website:>3} / {n_total} ({n_website/n_total*100:.0f}%)")
|
||||
print(f" Stage 2 careers found {n_careers:>3} / {n_website} ({n_careers/max(n_website,1)*100:.0f}% of resolved)")
|
||||
print(f" Stage 3 position found {n_position:>3} / {n_careers} ({n_position/max(n_careers,1)*100:.0f}% of careers)")
|
||||
print(f" Errors {n_errors:>3}")
|
||||
|
||||
# careers method breakdown
|
||||
if n_careers:
|
||||
method_counts: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.careers_method and r.careers_url:
|
||||
method_counts[r.careers_method] = method_counts.get(r.careers_method, 0) + 1
|
||||
print(f"\n Careers method breakdown:")
|
||||
for m, c in sorted(method_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {m:<30} {c}")
|
||||
|
||||
# position method breakdown
|
||||
if n_position:
|
||||
pos_method_counts: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.position_method and r.position_url:
|
||||
pos_method_counts[r.position_method] = pos_method_counts.get(r.position_method, 0) + 1
|
||||
print(f"\n Position method breakdown:")
|
||||
for m, c in sorted(pos_method_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {m:<30} {c}")
|
||||
|
||||
# CSV-ready output
|
||||
print(f"\n{'='*64}")
|
||||
print(" company_name, career_page_url, open_position_url")
|
||||
print(f"{'='*64}")
|
||||
for r in results:
|
||||
print(f" {r.company!r}, {r.careers_url or ''!r}, {r.position_url or ''!r}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -550,3 +550,112 @@ class TestSlugGuessTier:
|
||||
result = find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert result.method == "url_pattern"
|
||||
assert result.careers_url == "https://acme.com/careers"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 5 — Cheap-LLM careers-link classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLLMClassifyTier:
|
||||
"""Tier 5 fires after sitemap, no-ops when LLM returns None, and is skipped on earlier hits."""
|
||||
|
||||
def _patch_all_deterministic_miss(self, monkeypatch, *, homepage_html: str = "<html/>") -> None:
|
||||
"""Patch tiers 1–4 to all miss; leaves Tier 5 for the caller to control."""
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._safe_get_html",
|
||||
lambda website, client: homepage_html,
|
||||
)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_and_fetch", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.recover_via_slug_guess", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._heuristics.probe_url_patterns", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._heuristics.scan_homepage_links", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._heuristics.parse_sitemap", lambda *a, **kw: None)
|
||||
# Prevent _finalize from making network calls
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_ats_in_url", lambda url: None)
|
||||
|
||||
def test_llm_classify_hit_returns_055_confidence(self, monkeypatch):
|
||||
from jobsource.careers.classify_llm import Anchor, CareerLinkResult
|
||||
|
||||
self._patch_all_deterministic_miss(
|
||||
monkeypatch,
|
||||
homepage_html='<html><body><a href="/careers">Careers</a></body></html>',
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.extract_anchors",
|
||||
lambda html, base_url: [Anchor(url="https://acme.com/careers", text="Careers")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.classify_careers_link",
|
||||
lambda anchors: CareerLinkResult(careers_url="https://acme.com/careers", confidence=0.85),
|
||||
)
|
||||
result = find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert result.method == "llm_classify"
|
||||
assert result.confidence == 0.55 # fixed tier-level confidence from cascade
|
||||
assert result.careers_url == "https://acme.com/careers"
|
||||
|
||||
def test_llm_classify_none_falls_through_to_method_none(self, monkeypatch):
|
||||
from jobsource.careers.classify_llm import Anchor
|
||||
|
||||
self._patch_all_deterministic_miss(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.extract_anchors",
|
||||
lambda html, base_url: [Anchor(url="https://acme.com/about", text="About")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.classify_careers_link",
|
||||
lambda anchors: None,
|
||||
)
|
||||
result = find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert result.method == "none"
|
||||
assert result.careers_url is None
|
||||
|
||||
def test_tier5_skipped_when_homepage_html_is_none(self, monkeypatch):
|
||||
"""When the homepage fetch returns None, Tier 5 is silently bypassed."""
|
||||
llm_called: list[bool] = []
|
||||
self._patch_all_deterministic_miss(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._safe_get_html",
|
||||
lambda website, client: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.classify_careers_link",
|
||||
lambda anchors: llm_called.append(True) or None,
|
||||
)
|
||||
find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert llm_called == []
|
||||
|
||||
def test_tier5_not_called_when_sitemap_hits(self, monkeypatch):
|
||||
"""When sitemap resolves, the cascade returns before reaching Tier 5."""
|
||||
llm_called: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._safe_get_html",
|
||||
lambda website, client: "<html/>",
|
||||
)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_and_fetch", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.recover_via_slug_guess", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._heuristics.probe_url_patterns", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._heuristics.scan_homepage_links", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._heuristics.parse_sitemap",
|
||||
lambda *a, **kw: "https://acme.com/careers",
|
||||
)
|
||||
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_ats_in_url", lambda url: None)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.classify_careers_link",
|
||||
lambda anchors: llm_called.append(True) or None,
|
||||
)
|
||||
result = find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert result.method == "sitemap"
|
||||
assert llm_called == []
|
||||
|
||||
def test_tier5_error_falls_through_gracefully(self, monkeypatch):
|
||||
"""A Tier 5 exception should not abort the cascade."""
|
||||
self._patch_all_deterministic_miss(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.cascade._classify_llm.extract_anchors",
|
||||
lambda html, base_url: (_ for _ in ()).throw(RuntimeError("classify boom")),
|
||||
)
|
||||
result = find_careers_page("https://www.acme.com", client=FakeClient())
|
||||
assert result.method == "none"
|
||||
assert result.careers_url is None
|
||||
|
||||
256
tests/test_classify_llm.py
Normal file
256
tests/test_classify_llm.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Tests for careers/classify_llm.py — all network-free via monkeypatching."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from jobsource.careers.classify_llm import (
|
||||
Anchor,
|
||||
CareerLinkResult,
|
||||
JobLinkResult,
|
||||
_LinkChoice,
|
||||
classify_careers_link,
|
||||
classify_job_link,
|
||||
extract_anchors,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
"""Minimal stand-in for a Pydantic AI run result."""
|
||||
|
||||
def __init__(self, output: _LinkChoice) -> None:
|
||||
self.output = output
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Stand-in for a Pydantic AI Agent; returns a fixed _LinkChoice."""
|
||||
|
||||
def __init__(self, choice: _LinkChoice) -> None:
|
||||
self._choice = choice
|
||||
|
||||
def run_sync(self, prompt: str) -> _FakeResult:
|
||||
return _FakeResult(self._choice)
|
||||
|
||||
|
||||
class _RaisingAgent:
|
||||
"""Stand-in that raises on run_sync, simulating an LLM failure."""
|
||||
|
||||
def run_sync(self, prompt: str) -> _FakeResult:
|
||||
raise RuntimeError("LLM unavailable")
|
||||
|
||||
|
||||
def _real_settings():
|
||||
"""Fake settings object with non-placeholder values."""
|
||||
return type("S", (), {
|
||||
"classifier_model": "openai:gpt-4o-mini",
|
||||
"llm_api_key": "sk-real-key",
|
||||
})()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_anchors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE_HTML = """
|
||||
<html><body>
|
||||
<a href="/about">About Us</a>
|
||||
<a href="/careers">Careers</a>
|
||||
<a href="https://external.com/jobs">External Jobs</a>
|
||||
<a href="mailto:info@acme.com">Email</a>
|
||||
<a href="#section">Anchor</a>
|
||||
<a href="javascript:void(0)">JS link</a>
|
||||
<a href="/careers">Duplicate careers link</a>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractAnchors:
|
||||
def test_resolves_relative_to_absolute(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
urls = [a.url for a in anchors]
|
||||
assert "https://acme.com/about" in urls
|
||||
assert "https://acme.com/careers" in urls
|
||||
|
||||
def test_preserves_already_absolute_urls(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
urls = [a.url for a in anchors]
|
||||
assert "https://external.com/jobs" in urls
|
||||
|
||||
def test_skips_mailto(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
assert all("mailto:" not in a.url for a in anchors)
|
||||
|
||||
def test_skips_hash_fragments(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
# A pure # anchor resolves to base_url itself (https://acme.com) —
|
||||
# it starts with http so it passes the scheme check but we skip hrefs
|
||||
# that start with "#". Verify the JavaScript link is also gone.
|
||||
assert all("javascript:" not in a.url for a in anchors)
|
||||
|
||||
def test_deduplicates_by_url(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
urls = [a.url for a in anchors]
|
||||
assert urls.count("https://acme.com/careers") == 1
|
||||
|
||||
def test_captures_anchor_text(self):
|
||||
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
|
||||
career_anchor = next(a for a in anchors if a.url == "https://acme.com/careers")
|
||||
assert career_anchor.text == "Careers"
|
||||
|
||||
def test_empty_html_returns_empty_list(self):
|
||||
assert extract_anchors("", "https://acme.com") == []
|
||||
|
||||
def test_html_with_no_links_returns_empty_list(self):
|
||||
assert extract_anchors("<html><body><p>No links here.</p></body></html>", "https://acme.com") == []
|
||||
|
||||
def test_text_truncated_at_max_length(self):
|
||||
long_text = "x" * 200
|
||||
html = f'<html><body><a href="/careers">{long_text}</a></body></html>'
|
||||
anchors = extract_anchors(html, "https://acme.com")
|
||||
assert len(anchors) == 1
|
||||
assert len(anchors[0].text) <= 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graceful degradation: placeholder settings → always None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlaceholderGate:
|
||||
"""When classifier_model or llm_api_key is a placeholder, classifiers no-op."""
|
||||
|
||||
def test_classify_careers_link_returns_none_with_placeholder(self):
|
||||
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
|
||||
assert classify_careers_link(anchors) is None
|
||||
|
||||
def test_classify_job_link_returns_none_with_placeholder(self):
|
||||
anchors = [Anchor(url="https://acme.com/jobs/123", text="Engineer")]
|
||||
assert classify_job_link(anchors) is None
|
||||
|
||||
def test_classify_careers_link_returns_none_on_empty_list(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm.get_settings",
|
||||
_real_settings,
|
||||
)
|
||||
assert classify_careers_link([]) is None
|
||||
|
||||
def test_classify_job_link_returns_none_on_empty_list(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm.get_settings",
|
||||
_real_settings,
|
||||
)
|
||||
assert classify_job_link([]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Success paths via _build_link_agent monkeypatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassifySuccess:
|
||||
def _patch_agent(self, monkeypatch, choice: _LinkChoice) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm.get_settings",
|
||||
_real_settings,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
lambda instructions: _FakeAgent(choice),
|
||||
)
|
||||
|
||||
def test_careers_pick_returns_mapped_url(self, monkeypatch):
|
||||
anchors = [
|
||||
Anchor(url="https://acme.com/about", text="About"),
|
||||
Anchor(url="https://acme.com/careers", text="Careers"),
|
||||
]
|
||||
self._patch_agent(monkeypatch, _LinkChoice(index=1, confidence=0.95))
|
||||
result = classify_careers_link(anchors)
|
||||
assert result is not None
|
||||
assert isinstance(result, CareerLinkResult)
|
||||
assert result.careers_url == "https://acme.com/careers"
|
||||
assert result.confidence == 0.95
|
||||
|
||||
def test_job_pick_returns_mapped_url(self, monkeypatch):
|
||||
anchors = [
|
||||
Anchor(url="https://acme.com/jobs/", text="All Jobs"),
|
||||
Anchor(url="https://acme.com/jobs/123", text="Software Engineer"),
|
||||
]
|
||||
self._patch_agent(monkeypatch, _LinkChoice(index=1, confidence=0.85))
|
||||
result = classify_job_link(anchors)
|
||||
assert result is not None
|
||||
assert isinstance(result, JobLinkResult)
|
||||
assert result.job_url == "https://acme.com/jobs/123"
|
||||
assert result.confidence == 0.85
|
||||
|
||||
def test_careers_first_index_zero(self, monkeypatch):
|
||||
anchors = [Anchor(url="https://acme.com/jobs", text="Jobs")]
|
||||
self._patch_agent(monkeypatch, _LinkChoice(index=0, confidence=0.70))
|
||||
result = classify_careers_link(anchors)
|
||||
assert result is not None
|
||||
assert result.careers_url == "https://acme.com/jobs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstain and failure paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassifyFailurePaths:
|
||||
def _patch_real_settings(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm.get_settings",
|
||||
_real_settings,
|
||||
)
|
||||
|
||||
def test_abstain_index_minus_one_returns_none(self, monkeypatch):
|
||||
self._patch_real_settings(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
lambda instructions: _FakeAgent(_LinkChoice(index=-1, confidence=0.0)),
|
||||
)
|
||||
anchors = [Anchor(url="https://acme.com/about", text="About")]
|
||||
assert classify_careers_link(anchors) is None
|
||||
|
||||
def test_out_of_range_index_returns_none(self, monkeypatch):
|
||||
self._patch_real_settings(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
lambda instructions: _FakeAgent(_LinkChoice(index=99, confidence=0.9)),
|
||||
)
|
||||
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
|
||||
assert classify_careers_link(anchors) is None
|
||||
|
||||
def test_run_sync_exception_returns_none(self, monkeypatch):
|
||||
self._patch_real_settings(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
lambda instructions: _RaisingAgent(),
|
||||
)
|
||||
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
|
||||
assert classify_careers_link(anchors) is None
|
||||
|
||||
def test_build_agent_exception_returns_none(self, monkeypatch):
|
||||
self._patch_real_settings(monkeypatch)
|
||||
|
||||
def boom(instructions: str):
|
||||
raise RuntimeError("import error")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
boom,
|
||||
)
|
||||
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
|
||||
assert classify_careers_link(anchors) is None
|
||||
|
||||
def test_job_classify_run_sync_exception_returns_none(self, monkeypatch):
|
||||
self._patch_real_settings(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"jobsource.careers.classify_llm._build_link_agent",
|
||||
lambda instructions: _RaisingAgent(),
|
||||
)
|
||||
anchors = [Anchor(url="https://acme.com/jobs/123", text="Engineer")]
|
||||
assert classify_job_link(anchors) is None
|
||||
309
tests/test_extract.py
Normal file
309
tests/test_extract.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Tests for extract.py (Stage 3) — all network-free via monkeypatching."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from jobsource.careers.ats import ATSBoard, ATSFetch
|
||||
from jobsource.careers.classify_llm import Anchor, JobLinkResult
|
||||
from jobsource.extract import _find_job_posting_url, _jsonld_job_url, extract_open_position
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, text: str = "", url: str = "https://example.com") -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.url = url
|
||||
|
||||
|
||||
class FakeClient:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_job_posting_url (pure function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindJobPostingUrl:
|
||||
def test_flat_dict_job_posting(self):
|
||||
data = {"@type": "JobPosting", "url": "https://acme.com/jobs/1"}
|
||||
assert _find_job_posting_url(data) == "https://acme.com/jobs/1"
|
||||
|
||||
def test_list_of_nodes(self):
|
||||
data = [
|
||||
{"@type": "Organization", "name": "Acme"},
|
||||
{"@type": "JobPosting", "url": "https://acme.com/jobs/2"},
|
||||
]
|
||||
assert _find_job_posting_url(data) == "https://acme.com/jobs/2"
|
||||
|
||||
def test_graph_expansion(self):
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{"@type": "WebPage"},
|
||||
{"@type": "JobPosting", "url": "https://acme.com/jobs/3"},
|
||||
],
|
||||
}
|
||||
assert _find_job_posting_url(data) == "https://acme.com/jobs/3"
|
||||
|
||||
def test_type_as_list(self):
|
||||
data = {"@type": ["Thing", "JobPosting"], "url": "https://acme.com/jobs/4"}
|
||||
assert _find_job_posting_url(data) == "https://acme.com/jobs/4"
|
||||
|
||||
def test_no_job_posting_returns_none(self):
|
||||
data = {"@type": "Organization", "name": "Acme"}
|
||||
assert _find_job_posting_url(data) is None
|
||||
|
||||
def test_job_posting_without_url_returns_none(self):
|
||||
data = {"@type": "JobPosting"}
|
||||
assert _find_job_posting_url(data) is None
|
||||
|
||||
def test_scalar_input_returns_none(self):
|
||||
assert _find_job_posting_url("not a dict") is None
|
||||
assert _find_job_posting_url(42) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _jsonld_job_url (parses HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonldJobUrl:
|
||||
def test_finds_url_in_json_ld_block(self):
|
||||
html = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@type": "JobPosting", "url": "https://acme.com/jobs/99"}
|
||||
</script>
|
||||
</head></html>
|
||||
"""
|
||||
assert _jsonld_job_url(html) == "https://acme.com/jobs/99"
|
||||
|
||||
def test_invalid_json_block_skipped(self):
|
||||
html = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">NOT JSON{{</script>
|
||||
<script type="application/ld+json">{"@type": "JobPosting", "url": "https://ok.com/job/1"}</script>
|
||||
</head></html>
|
||||
"""
|
||||
assert _jsonld_job_url(html) == "https://ok.com/job/1"
|
||||
|
||||
def test_no_job_posting_returns_none(self):
|
||||
html = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">{"@type": "Organization"}</script>
|
||||
</head></html>
|
||||
"""
|
||||
assert _jsonld_job_url(html) is None
|
||||
|
||||
def test_no_script_returns_none(self):
|
||||
assert _jsonld_job_url("<html><body>No scripts</body></html>") 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, "<html><body><a href='/team'>Team</a></body></html>"),
|
||||
)
|
||||
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, '<script src="greenhouse"></script>'),
|
||||
)
|
||||
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 = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@type": "JobPosting", "url": "https://acme.com/jobs/77"}
|
||||
</script>
|
||||
</head><body></body></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 = """
|
||||
<html><body>
|
||||
<a href="/about">About</a>
|
||||
<a href="/jobs/software-engineer-123">Software Engineer</a>
|
||||
</body></html>
|
||||
"""
|
||||
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 = '<html><body><a href="/openings/42">Open Role</a></body></html>'
|
||||
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 = '<html><body><a href="/about">About</a><a href="/team">Team</a></body></html>'
|
||||
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 = '<html><body><a href="/about">About</a></body></html>'
|
||||
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 = '<html><body><a href="/about">About</a></body></html>'
|
||||
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 = '<html><body><a href="/jobs/42">Engineer</a></body></html>'
|
||||
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"
|
||||
Reference in New Issue
Block a user