20\d\d)\)$"
@@ -49,6 +64,37 @@ def collect(self) -> list[JobLeadInput]:
"""Return current leads from the source."""
+@dataclass(frozen=True)
+class JobLeadClassification:
+ """Contractor-friendliness classification for one external lead."""
+
+ is_contractor_friendly: bool
+ posting_type: JobPostingType
+ tags: list[str]
+ confidence: float
+ confidence_label: Literal["high", "medium", "low"]
+ rationale: str
+ method: Literal["llm", "heuristic"]
+
+
+class JobLeadLLMClassificationResponse(BaseModel):
+ """Schema-backed model response for contractor-friendly lead detection."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ is_contractor_friendly: bool
+ posting_type: Literal[
+ "part_time",
+ "full_time",
+ "part_time_or_full_time",
+ "unknown",
+ ] = "unknown"
+ tags: list[str] = Field(default_factory=list)
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0)
+ confidence_label: Literal["high", "medium", "low"] = "low"
+ rationale: str = ""
+
+
class _TextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
@@ -174,12 +220,272 @@ def _contract_tags_and_confidence(text: str) -> tuple[list[str], float]:
return sorted(set(tags)), min(confidence, 1.0)
+def _confidence_label(confidence: float) -> Literal["high", "medium", "low"]:
+ if confidence >= 0.75:
+ return "high"
+ if confidence >= 0.45:
+ return "medium"
+ return "low"
+
+
def classify_contractor_lead(comment_text: str) -> tuple[bool, list[str], float]:
"""Return whether a HN job post looks contractor-friendly."""
+ classification = classify_contractor_lead_heuristic(comment_text)
+ return (
+ classification.is_contractor_friendly,
+ classification.tags,
+ classification.confidence,
+ )
+
+
+def classify_contractor_lead_heuristic(comment_text: str) -> JobLeadClassification:
+ """Return a deterministic fallback contractor-friendly classification."""
if _SEEKING_WORK_RE.search(comment_text):
- return False, [], 0.0
+ return JobLeadClassification(
+ is_contractor_friendly=False,
+ posting_type=JobPostingType.UNKNOWN,
+ tags=[],
+ confidence=0.0,
+ confidence_label="low",
+ rationale="Post is a SEEKING WORK comment, not an employer lead.",
+ method="heuristic",
+ )
tags, confidence = _contract_tags_and_confidence(comment_text)
- return bool(tags) and confidence >= 0.20, tags, confidence
+ is_lead = bool(tags) and confidence >= 0.20
+ return JobLeadClassification(
+ is_contractor_friendly=is_lead,
+ posting_type=JobPostingType.PART_TIME if is_lead else JobPostingType.UNKNOWN,
+ tags=tags,
+ confidence=confidence,
+ confidence_label=_confidence_label(confidence),
+ rationale=(
+ f"Matched contractor-friendly terms: {', '.join(tags)}."
+ if tags
+ else "No contractor-friendly terms were matched."
+ ),
+ method="heuristic",
+ )
+
+
+class JobLeadClassifier:
+ """LLM-first classifier with deterministic keyword fallback."""
+
+ def __init__(
+ self,
+ *,
+ settings: SharedSettings,
+ client: Any | None = None,
+ ) -> None:
+ self.settings = settings
+ self.client = client if client is not None else _build_llm_client(settings)
+
+ def classify(self, comment_text: str) -> JobLeadClassification:
+ if _SEEKING_WORK_RE.search(comment_text):
+ return classify_contractor_lead_heuristic(comment_text)
+ if self.client is not None:
+ try:
+ return self._classify_with_llm(comment_text)
+ except Exception as exc:
+ logger.warning("Job lead LLM classification failed: %s", exc)
+ return classify_contractor_lead_heuristic(comment_text)
+
+ @staticmethod
+ def _messages(comment_text: str) -> list[dict[str, str]]:
+ return [
+ {
+ "role": "system",
+ "content": (
+ "You classify Hacker News 'Who is hiring?' employer posts for "
+ "508.dev job-lead review. Return JSON only. A contractor-friendly "
+ "lead explicitly allows contract, contractor, 1099, freelance, "
+ "consulting, fractional, part-time, B2B contracting, or both "
+ "full-time and contract arrangements. Reject full-time employee-only "
+ "roles, generic company B2B descriptions, replies, and SEEKING WORK "
+ "comments. Prefer explicit evidence over inference. Tags must be "
+ "short lowercase evidence labels such as contract, 1099, freelance, "
+ "consulting, fractional, part-time, b2b-contracting, remote."
+ ),
+ },
+ {
+ "role": "user",
+ "content": f"Classify this HN comment:\n\n{comment_text[:12000]}",
+ },
+ ]
+
+ @staticmethod
+ def _coerce_message_content_to_text(value: Any) -> str | None:
+ if isinstance(value, str):
+ stripped = value.strip()
+ return stripped or None
+ return None
+
+ def _classify_with_llm(self, comment_text: str) -> JobLeadClassification:
+ client = self.client
+ if client is None:
+ raise RuntimeError("Job lead LLM client is unavailable")
+ messages = self._messages(comment_text)
+ if _supports_structured_output(client):
+ response = client.beta.chat.completions.parse(
+ model=_classifier_model(self.settings),
+ messages=messages,
+ response_format=JobLeadLLMClassificationResponse,
+ max_tokens=700,
+ temperature=0,
+ )
+ parsed_model = _parsed_message_model(response)
+ if parsed_model is None:
+ raise ValueError("Empty structured job lead classification response")
+ return _classification_from_llm_response(parsed_model)
+
+ response = client.chat.completions.create(
+ model=_classifier_model(self.settings),
+ messages=messages,
+ response_format={"type": "json_object"},
+ max_tokens=700,
+ temperature=0,
+ )
+ raw_content = self._coerce_message_content_to_text(
+ _first_message_content(response)
+ )
+ if not raw_content:
+ raise ValueError("Empty job lead classification response")
+ return _classification_from_llm_response(
+ JobLeadLLMClassificationResponse.model_validate_json(raw_content)
+ )
+
+
+def _clean(value: object) -> str | None:
+ if value is None:
+ return None
+ stripped = str(value).strip()
+ return stripped or None
+
+
+def _classifier_model(settings: SharedSettings) -> str:
+ return (
+ _clean(getattr(settings, "job_lead_classifier_model", None))
+ or _clean(getattr(settings, "agent_fast_model", None))
+ or _clean(getattr(settings, "agent_fallback_model", None))
+ or _clean(getattr(settings, "openai_model", None))
+ or DEFAULT_JOB_LEAD_CLASSIFIER_MODEL
+ )
+
+
+def _fireworks_classifier_model(settings: SharedSettings) -> str | None:
+ explicit_fast = _clean(getattr(settings, "agent_fast_model", None))
+ if explicit_fast:
+ return explicit_fast
+ classifier_model = _classifier_model(settings)
+ if classifier_model.startswith(("accounts/fireworks/", "fireworks/")):
+ return classifier_model
+ return None
+
+
+def _openrouter_classifier_model(settings: SharedSettings) -> str | None:
+ explicit_fast = _clean(getattr(settings, "agent_fast_model", None))
+ if explicit_fast:
+ return explicit_fast
+ classifier_model = _classifier_model(settings)
+ if classifier_model.startswith(("openai/", "openrouter/")):
+ return classifier_model
+ return None
+
+
+def _build_llm_client(settings: SharedSettings) -> Any | None:
+ if getattr(settings, "job_lead_classifier_enabled", True) is False:
+ return None
+ if OpenAIClient is None:
+ return None
+ providers = build_openai_compatible_provider_attempts(
+ primary_model=_classifier_model(settings),
+ primary_api_key=_clean(getattr(settings, "agent_fast_api_key", None))
+ or _clean(getattr(settings, "openai_api_key", None)),
+ primary_base_url=_clean(getattr(settings, "agent_fast_base_url", None))
+ or _clean(getattr(settings, "openai_base_url", None)),
+ openai_direct_api_key=_clean(getattr(settings, "openai_direct_api_key", None))
+ or _clean(getattr(settings, "openai_api_key_direct", None)),
+ openai_direct_base_url=_clean(
+ getattr(settings, "openai_direct_base_url", None)
+ ),
+ openai_direct_model=_clean(getattr(settings, "openai_direct_model", None))
+ or _clean(getattr(settings, "agent_fallback_model", None)),
+ fireworks_api_key=_clean(getattr(settings, "fireworks_api_key", None)),
+ fireworks_model=_fireworks_classifier_model(settings),
+ openrouter_api_key=_clean(getattr(settings, "openrouter_api_key", None)),
+ openrouter_model=_openrouter_classifier_model(settings),
+ )
+ if not providers:
+ return None
+ return FallbackOpenAIClient(
+ providers=providers,
+ client_factory=OpenAIClient,
+ timeout_seconds=float(
+ getattr(settings, "job_lead_classifier_timeout_seconds", 8.0) or 8.0
+ ),
+ )
+
+
+def _supports_structured_output(client: Any) -> bool:
+ beta = getattr(client, "beta", None)
+ chat = getattr(beta, "chat", None)
+ completions = getattr(chat, "completions", None)
+ return hasattr(completions, "parse")
+
+
+def _parsed_message_model(response: Any) -> JobLeadLLMClassificationResponse | None:
+ choices = getattr(response, "choices", None)
+ first_choice = choices[0] if choices else None
+ message = getattr(first_choice, "message", None)
+ parsed = getattr(message, "parsed", None) if message else None
+ if isinstance(parsed, JobLeadLLMClassificationResponse):
+ return parsed
+ if parsed is not None:
+ return JobLeadLLMClassificationResponse.model_validate(parsed)
+ return None
+
+
+def _first_message_content(response: Any) -> Any:
+ choices = getattr(response, "choices", None)
+ first_choice = choices[0] if choices else None
+ message = getattr(first_choice, "message", None)
+ return getattr(message, "content", None) if message else None
+
+
+def _classification_from_llm_response(
+ response: JobLeadLLMClassificationResponse,
+) -> JobLeadClassification:
+ tags = sorted(
+ {
+ tag.strip().casefold()
+ for tag in response.tags
+ if isinstance(tag, str) and tag.strip()
+ }
+ )
+ return JobLeadClassification(
+ is_contractor_friendly=response.is_contractor_friendly,
+ posting_type=JobPostingType(response.posting_type),
+ tags=tags,
+ confidence=max(0.0, min(1.0, float(response.confidence))),
+ confidence_label=response.confidence_label,
+ rationale=response.rationale.strip()[:500],
+ method="llm",
+ )
+
+
+def _classification_metadata(
+ classification: JobLeadClassification,
+) -> dict[str, Any]:
+ return {
+ "contractor_classification": {
+ "is_contractor_friendly": classification.is_contractor_friendly,
+ "posting_type": classification.posting_type.value,
+ "tags": classification.tags,
+ "confidence": classification.confidence,
+ "confidence_label": classification.confidence_label,
+ "rationale": classification.rationale,
+ "method": classification.method,
+ }
+ }
def _lead_from_hn_comment(
@@ -187,12 +493,19 @@ def _lead_from_hn_comment(
story_id: int,
story_title: str,
comment: dict[str, Any],
+ classifier: JobLeadClassifier | None = None,
) -> JobLeadInput | None:
text = html_to_text(comment.get("text"))
if not text:
return None
- is_lead, tags, confidence = classify_contractor_lead(text)
- if not is_lead:
+ if _SEEKING_WORK_RE.search(text):
+ return None
+ classification = (
+ classifier.classify(text)
+ if classifier is not None
+ else classify_contractor_lead_heuristic(text)
+ )
+ if not classification.is_contractor_friendly:
return None
header_parts = _split_header(text)
@@ -215,6 +528,7 @@ def _lead_from_hn_comment(
"hn_story_title": story_title,
"hn_author": comment.get("author"),
"hn_parent_id": comment.get("parent_id"),
+ **_classification_metadata(classification),
}
return JobLeadInput(
source_key=HN_WHO_IS_HIRING_SOURCE_KEY,
@@ -227,12 +541,12 @@ def _lead_from_hn_comment(
organization=organization,
body_raw=str(comment.get("text") or ""),
body_normalized=text,
- posting_type=JobPostingType.PART_TIME,
+ posting_type=classification.posting_type,
location=location,
remote=bool(_REMOTE_RE.search(text)) if text else None,
apply_url=_first_url(text),
- tags=tags,
- confidence=confidence,
+ tags=classification.tags,
+ confidence=classification.confidence,
metadata=metadata,
)
@@ -246,10 +560,12 @@ def __init__(
self,
*,
client: HackerNewsClient | None = None,
+ classifier: JobLeadClassifier | None = None,
story_id: int | None = None,
include_latest: bool = True,
) -> None:
self.client = client or HackerNewsClient()
+ self.classifier = classifier
self.story_id = story_id
self.include_latest = include_latest
@@ -281,6 +597,7 @@ def collect(self) -> list[JobLeadInput]:
story_id=thread.story_id,
story_title=thread.title,
comment=child,
+ classifier=self.classifier,
)
if lead is not None:
leads.append(lead)
@@ -290,12 +607,16 @@ def collect(self) -> list[JobLeadInput]:
def build_job_lead_source(
source: str,
*,
+ classifier: JobLeadClassifier | None = None,
story_id: int | None = None,
) -> JobLeadSource:
"""Construct a source adapter by stable source id."""
normalized = source.strip().casefold()
if normalized in {"hn", "hackernews", HN_WHO_IS_HIRING_SOURCE_KEY}:
- return HackerNewsWhoIsHiringLeadSource(story_id=story_id)
+ return HackerNewsWhoIsHiringLeadSource(
+ classifier=classifier,
+ story_id=story_id,
+ )
raise ValueError(f"Unsupported job lead source: {source}")
@@ -306,7 +627,8 @@ def scrape_job_leads(
story_id: int | None = None,
) -> dict[str, Any]:
"""Collect leads from a source and upsert them for review."""
- adapter = build_job_lead_source(source, story_id=story_id)
+ classifier = JobLeadClassifier(settings=settings)
+ adapter = build_job_lead_source(source, classifier=classifier, story_id=story_id)
created = 0
updated = 0
lead_ids: list[str] = []
diff --git a/packages/shared/src/five08/job_leads.py b/packages/shared/src/five08/job_leads.py
index 26a7fea5..71aea798 100644
--- a/packages/shared/src/five08/job_leads.py
+++ b/packages/shared/src/five08/job_leads.py
@@ -81,6 +81,101 @@ class JobLead:
updated_at: datetime
+def job_lead_classification(lead: JobLead | dict[str, Any]) -> dict[str, Any]:
+ """Return normalized contractor-classification metadata for display."""
+ metadata = lead.get("metadata") if isinstance(lead, dict) else lead.metadata
+ metadata = metadata if isinstance(metadata, dict) else {}
+ classification = metadata.get("contractor_classification")
+ if isinstance(classification, dict):
+ return {
+ "is_contractor_friendly": bool(
+ classification.get("is_contractor_friendly", True)
+ ),
+ "posting_type": str(classification.get("posting_type") or ""),
+ "tags": [
+ str(tag) for tag in classification.get("tags", []) if str(tag).strip()
+ ],
+ "confidence": float(classification.get("confidence") or 0.0),
+ "confidence_label": str(classification.get("confidence_label") or "low"),
+ "rationale": str(classification.get("rationale") or "").strip(),
+ "method": str(classification.get("method") or "unknown"),
+ }
+
+ tags = lead.get("tags") if isinstance(lead, dict) else lead.tags
+ confidence = lead.get("confidence") if isinstance(lead, dict) else lead.confidence
+ tag_values = [str(tag) for tag in tags or [] if str(tag).strip()]
+ confidence_value = float(confidence or 0.0)
+ if confidence_value >= 0.75:
+ confidence_label = "high"
+ elif confidence_value >= 0.45:
+ confidence_label = "medium"
+ else:
+ confidence_label = "low"
+ return {
+ "is_contractor_friendly": bool(tag_values),
+ "posting_type": "",
+ "tags": tag_values,
+ "confidence": confidence_value,
+ "confidence_label": confidence_label,
+ "rationale": "",
+ "method": "heuristic",
+ }
+
+
+def format_job_lead_review_summary(lead: JobLead | dict[str, Any]) -> str:
+ """Return a human-readable summary of why a lead is review-worthy."""
+ classification = job_lead_classification(lead)
+ method = classification.get("method")
+ method_label = "LLM" if method == "llm" else "Keyword fallback"
+ confidence_label = str(classification.get("confidence_label") or "low")
+ tags = [str(tag) for tag in classification.get("tags", []) if str(tag).strip()]
+ tag_text = ", ".join(tags[:5]) if tags else "no evidence tags"
+ rationale = str(classification.get("rationale") or "").strip()
+ summary = f"{method_label}: {confidence_label} contractor fit; {tag_text}"
+ if rationale:
+ summary = f"{summary} - {rationale}"
+ return summary
+
+
+def job_lead_display_payload(lead: JobLead | dict[str, Any]) -> dict[str, Any]:
+ """Return API payload with shared classification display fields."""
+ if isinstance(lead, dict):
+ payload = dict(lead)
+ else:
+ payload = {
+ "id": lead.id,
+ "status": lead.status.value,
+ "source_key": lead.source_key,
+ "source_type": lead.source_type,
+ "external_id": lead.external_id,
+ "external_parent_id": lead.external_parent_id,
+ "source_url": lead.source_url,
+ "source_posted_at": lead.source_posted_at,
+ "title": lead.title,
+ "organization": lead.organization,
+ "body_raw": lead.body_raw,
+ "body_normalized": lead.body_normalized,
+ "posting_type": lead.posting_type.value,
+ "location": lead.location,
+ "remote": lead.remote,
+ "apply_url": lead.apply_url,
+ "tags": lead.tags,
+ "confidence": lead.confidence,
+ "metadata": lead.metadata,
+ "reviewed_by_discord_user_id": lead.reviewed_by_discord_user_id,
+ "reviewed_at": lead.reviewed_at,
+ "discord_guild_id": lead.discord_guild_id,
+ "discord_channel_id": lead.discord_channel_id,
+ "discord_thread_id": lead.discord_thread_id,
+ "posted_at": lead.posted_at,
+ "created_at": lead.created_at,
+ "updated_at": lead.updated_at,
+ }
+ payload["contractor_classification"] = job_lead_classification(lead)
+ payload["review_summary"] = format_job_lead_review_summary(lead)
+ return payload
+
+
def _normalize_status(value: str | JobLeadStatus | None) -> JobLeadStatus:
if isinstance(value, JobLeadStatus):
return value
diff --git a/packages/shared/src/five08/runtime_config.py b/packages/shared/src/five08/runtime_config.py
index 0679554b..9984ebc7 100644
--- a/packages/shared/src/five08/runtime_config.py
+++ b/packages/shared/src/five08/runtime_config.py
@@ -391,6 +391,33 @@ class RuntimeConfigDBSnapshot:
is_secret=True,
env_names=("OPENROUTER_API_KEY",),
),
+ RuntimeConfigDefinition(
+ key="JOB_LEAD_CLASSIFIER_ENABLED",
+ attr="job_lead_classifier_enabled",
+ label="Job lead classifier enabled",
+ category="AI",
+ description="Use an LLM to classify scraped HN job leads before keyword fallback.",
+ value_type="bool",
+ env_names=("JOB_LEAD_CLASSIFIER_ENABLED",),
+ ),
+ RuntimeConfigDefinition(
+ key="JOB_LEAD_CLASSIFIER_MODEL",
+ attr="job_lead_classifier_model",
+ label="Job lead classifier model",
+ category="AI",
+ description="Optional model override for HN job lead classification.",
+ env_names=("JOB_LEAD_CLASSIFIER_MODEL",),
+ ),
+ RuntimeConfigDefinition(
+ key="JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS",
+ attr="job_lead_classifier_timeout_seconds",
+ label="Job lead classifier timeout seconds",
+ category="AI",
+ description="Timeout in seconds for each job lead classification LLM call.",
+ value_type="float",
+ env_names=("JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS",),
+ min_value=0.1,
+ ),
RuntimeConfigDefinition(
key="RESUME_AI_API_KEY",
attr="resume_ai_api_key",
diff --git a/tests/integration/test_dashboard_playwright.py b/tests/integration/test_dashboard_playwright.py
index bc762605..c8bb8603 100644
--- a/tests/integration/test_dashboard_playwright.py
+++ b/tests/integration/test_dashboard_playwright.py
@@ -338,6 +338,19 @@ def _job_leads_payload() -> list[dict[str, object]]:
"apply_url": "https://example.com/jobs/react",
"tags": ["1099", "contract"],
"confidence": 0.8,
+ "contractor_classification": {
+ "is_contractor_friendly": True,
+ "posting_type": "part_time",
+ "tags": ["1099", "contract"],
+ "confidence": 0.8,
+ "confidence_label": "high",
+ "rationale": "Explicitly asks for a remote 1099 contractor.",
+ "method": "llm",
+ },
+ "review_summary": (
+ "LLM: high contractor fit; 1099, contract - "
+ "Explicitly asks for a remote 1099 contractor."
+ ),
"reviewed_by_discord_user_id": None,
"reviewed_at": None,
"discord_guild_id": None,
diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py
index e669cdc5..c53170dc 100644
--- a/tests/unit/test_backend_api.py
+++ b/tests/unit/test_backend_api.py
@@ -3791,7 +3791,17 @@ def test_dashboard_job_leads_returns_pending_leads(client: TestClient) -> None:
response = client.get("/dashboard/api/gig-leads?status=pending&limit=10")
assert response.status_code == 200
- assert response.json() == leads
+ payload = response.json()
+ assert payload[0] | {
+ "contractor_classification": None,
+ "review_summary": None,
+ } == leads[0] | {
+ "contractor_classification": None,
+ "review_summary": None,
+ }
+ assert payload[0]["contractor_classification"]["method"] == "heuristic"
+ assert payload[0]["contractor_classification"]["tags"] == ["contract"]
+ assert payload[0]["review_summary"].startswith("Keyword fallback:")
mock_leads.assert_called_once_with(
api.settings,
status=api.JobLeadStatus.PENDING,
@@ -3891,7 +3901,15 @@ def test_dashboard_review_job_lead_approves_with_discord_reviewer(
)
assert response.status_code == 200
- assert response.json() == reviewed
+ payload = response.json()
+ assert payload | {
+ "contractor_classification": None,
+ "review_summary": None,
+ } == reviewed | {
+ "contractor_classification": None,
+ "review_summary": None,
+ }
+ assert payload["contractor_classification"]["method"] == "heuristic"
mock_review.assert_called_once_with(
api.settings,
lead_id="lead-1",
diff --git a/tests/unit/test_job_lead_sources.py b/tests/unit/test_job_lead_sources.py
index 37e0bf44..b3c2fa80 100644
--- a/tests/unit/test_job_lead_sources.py
+++ b/tests/unit/test_job_lead_sources.py
@@ -3,13 +3,18 @@
from __future__ import annotations
from datetime import datetime, timezone
+from types import SimpleNamespace
from five08.job_lead_sources import (
HackerNewsThread,
HackerNewsWhoIsHiringLeadSource,
+ JobLeadClassifier,
+ JobLeadClassification,
+ _build_llm_client,
classify_contractor_lead,
html_to_text,
)
+from five08.job_channels import JobPostingType
class _FakeHackerNewsClient:
@@ -57,6 +62,105 @@ def get_algolia_item_tree(self, item_id: int) -> dict:
}
+class _FakeJobLeadClassifier:
+ def classify(self, comment_text: str) -> JobLeadClassification:
+ if "Employee-only" in comment_text:
+ return JobLeadClassification(
+ is_contractor_friendly=False,
+ posting_type=JobPostingType.FULL_TIME,
+ tags=["employee"],
+ confidence=0.9,
+ confidence_label="high",
+ rationale="Full-time employee-only role.",
+ method="llm",
+ )
+ return JobLeadClassification(
+ is_contractor_friendly=True,
+ posting_type=JobPostingType.PART_TIME,
+ tags=["contract", "remote"],
+ confidence=0.88,
+ confidence_label="high",
+ rationale="Explicitly allows contract work.",
+ method="llm",
+ )
+
+
+class _PositiveJobLeadClassifier:
+ def classify(self, comment_text: str) -> JobLeadClassification:
+ return JobLeadClassification(
+ is_contractor_friendly=True,
+ posting_type=JobPostingType.PART_TIME,
+ tags=["freelance"],
+ confidence=0.95,
+ confidence_label="high",
+ rationale="Injected positive classification.",
+ method="llm",
+ )
+
+
+class _FakeClassifierHackerNewsClient(_FakeHackerNewsClient):
+ def get_algolia_item_tree(self, item_id: int) -> dict:
+ assert item_id == 48357725
+ return {
+ "id": item_id,
+ "children": [
+ {
+ "id": 10,
+ "parent_id": item_id,
+ "author": "company",
+ "created_at": "2026-06-01T15:02:00Z",
+ "text": "Acme | Backend Engineer | RemoteContract welcome.",
+ },
+ {
+ "id": 11,
+ "parent_id": item_id,
+ "author": "company",
+ "created_at": "2026-06-01T15:03:00Z",
+ "text": "Fulltime Co | Employee-only backend role | Remote",
+ },
+ ],
+ }
+
+
+class _FakeSeekingWorkHackerNewsClient(_FakeHackerNewsClient):
+ def get_algolia_item_tree(self, item_id: int) -> dict:
+ assert item_id == 48357725
+ return {
+ "id": item_id,
+ "children": [
+ {
+ "id": 20,
+ "parent_id": item_id,
+ "author": "person",
+ "created_at": "2026-06-01T15:02:00Z",
+ "text": "SEEKING WORK | Remote | Freelance Python engineer",
+ }
+ ],
+ }
+
+
+class _FakeFailingStructuredClient:
+ def __init__(self) -> None:
+ self.chat_create_calls = 0
+ self.parse_kwargs: dict[str, object] | None = None
+ self.beta = SimpleNamespace(
+ chat=SimpleNamespace(
+ completions=SimpleNamespace(parse=self._parse),
+ )
+ )
+ self.chat = SimpleNamespace(
+ completions=SimpleNamespace(create=self._create),
+ )
+
+ def _parse(self, **kwargs: object) -> object:
+ self.parse_kwargs = kwargs
+ raise TimeoutError("provider timed out")
+
+ def _create(self, **_kwargs: object) -> object:
+ self.chat_create_calls += 1
+ raise AssertionError("chat fallback should not run after provider failure")
+
+
def test_html_to_text_preserves_links_and_paragraph_breaks() -> None:
text = html_to_text('Hello
Apply')
@@ -88,3 +192,83 @@ def test_hacker_news_source_extracts_top_level_contractor_posts() -> None:
assert lead.apply_url == "https://acme.example/jobs"
assert {"contract", "1099"}.issubset(set(lead.tags or []))
assert lead.confidence >= 0.5
+ classification = lead.metadata["contractor_classification"]
+ assert classification["method"] == "heuristic"
+ assert classification["confidence_label"] == "high"
+
+
+def test_hacker_news_source_uses_injected_classifier_for_lead_filtering() -> None:
+ source = HackerNewsWhoIsHiringLeadSource(
+ client=_FakeClassifierHackerNewsClient(),
+ classifier=_FakeJobLeadClassifier(), # type: ignore[arg-type]
+ )
+
+ leads = source.collect()
+
+ assert [lead.external_id for lead in leads] == ["10"]
+ assert leads[0].tags == ["contract", "remote"]
+ assert leads[0].confidence == 0.88
+ classification = leads[0].metadata["contractor_classification"]
+ assert classification["method"] == "llm"
+ assert classification["rationale"] == "Explicitly allows contract work."
+
+
+def test_hacker_news_source_rejects_seeking_work_before_classifier() -> None:
+ source = HackerNewsWhoIsHiringLeadSource(
+ client=_FakeSeekingWorkHackerNewsClient(),
+ classifier=_PositiveJobLeadClassifier(), # type: ignore[arg-type]
+ )
+
+ assert source.collect() == []
+
+
+def test_classifier_falls_back_without_second_llm_call_after_provider_failure() -> None:
+ client = _FakeFailingStructuredClient()
+ classifier = JobLeadClassifier(
+ settings=SimpleNamespace(),
+ client=client,
+ )
+
+ classification = classifier.classify("Acme | Contract API Engineer | Remote")
+
+ assert classification.method == "heuristic"
+ assert classification.is_contractor_friendly is True
+ assert client.parse_kwargs is not None
+ assert client.parse_kwargs["temperature"] == 0
+ assert client.chat_create_calls == 0
+
+
+def test_build_llm_client_uses_classifier_model_for_fireworks_direct(
+ monkeypatch,
+) -> None:
+ class _OpenAIClient:
+ pass
+
+ monkeypatch.setattr(
+ "five08.job_lead_sources.OpenAIClient",
+ _OpenAIClient,
+ )
+ settings = SimpleNamespace(
+ job_lead_classifier_enabled=True,
+ job_lead_classifier_model="accounts/fireworks/models/kimi-k2p6",
+ agent_fast_model=None,
+ agent_fallback_model=None,
+ openai_model=None,
+ agent_fast_api_key=None,
+ openai_api_key=None,
+ agent_fast_base_url=None,
+ openai_base_url=None,
+ openai_direct_api_key=None,
+ openai_api_key_direct=None,
+ openai_direct_base_url=None,
+ openai_direct_model=None,
+ fireworks_api_key="fireworks-key",
+ openrouter_api_key=None,
+ job_lead_classifier_timeout_seconds=8.0,
+ )
+
+ client = _build_llm_client(settings) # type: ignore[arg-type]
+
+ assert client is not None
+ assert client.providers[0].label == "fireworks-direct"
+ assert client.providers[0].model == "accounts/fireworks/models/kimi-k2p6"
diff --git a/tests/unit/test_jobs.py b/tests/unit/test_jobs.py
index 088559cd..40a393fd 100644
--- a/tests/unit/test_jobs.py
+++ b/tests/unit/test_jobs.py
@@ -135,6 +135,67 @@ def test_format_job_lead_thread_content_includes_review_context() -> None:
assert "CO-Ver needs a 1099 contractor." in content
+def test_format_job_lead_review_line_explains_confidence_source() -> None:
+ lead = _make_job_lead(
+ metadata={
+ "contractor_classification": {
+ "is_contractor_friendly": True,
+ "posting_type": "part_time",
+ "tags": ["contract", "1099"],
+ "confidence": 0.91,
+ "confidence_label": "high",
+ "rationale": "Explicitly allows 1099 contract work.",
+ "method": "llm",
+ }
+ }
+ )
+
+ line = JobsCog._format_job_lead_review_line(1, lead)
+
+ assert "LLM: high contractor fit; contract, 1099" in line
+ assert "91%" not in line
+
+
+def test_format_job_lead_review_message_respects_discord_limit(monkeypatch) -> None:
+ monkeypatch.setattr(
+ type(jobs_module.settings),
+ "discord_sendmsg_character_limit",
+ property(lambda _settings: 600),
+ )
+ leads = [
+ _make_job_lead(
+ id=f"11111111-1111-1111-1111-11111111111{index}",
+ title=f"Contract role {index} " + ("x" * 100),
+ source_url=f"https://news.ycombinator.com/item?id={48000000 + index}",
+ metadata={
+ "contractor_classification": {
+ "is_contractor_friendly": True,
+ "posting_type": "part_time",
+ "tags": ["contract", "1099", "remote"],
+ "confidence": 0.91,
+ "confidence_label": "high",
+ "rationale": "Explicitly allows contract work. " + ("y" * 120),
+ "method": "llm",
+ }
+ },
+ )
+ for index in range(10)
+ ]
+
+ message = JobsCog._format_job_lead_review_message(leads)
+
+ assert len(message) <= jobs_module.settings.discord_sendmsg_character_limit
+ assert "Showing" in message
+ assert "Use a lower limit" in message
+
+
+def test_format_job_lead_review_message_handles_empty_leads() -> None:
+ assert (
+ JobsCog._format_job_lead_review_message([])
+ == "Pending job leads:\n\nNo pending job leads found."
+ )
+
+
def test_format_job_lead_thread_content_respects_discord_limit() -> None:
lead = _make_job_lead(body_normalized="x" * 5000)