careers-llm-extract

This commit is contained in:
ldy
2026-06-17 22:56:04 -04:00
parent 113a4ced36
commit 41037fb2f5
7 changed files with 1350 additions and 28 deletions

256
tests/test_classify_llm.py Normal file
View 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