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()
|
||||
|
||||
Reference in New Issue
Block a user