257 lines
9.5 KiB
Python
257 lines
9.5 KiB
Python
"""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
|