agent-fallback
This commit is contained in:
@@ -1,11 +1,301 @@
|
||||
"""Browser Use fused fallback: find careers page AND extract one job URL in one session.
|
||||
|
||||
Scaffold stub -- not implemented yet.
|
||||
This is the LAST tier of the cascade (Stage 2, Tier 6 / Stage 3, Tier 5). It fires only
|
||||
when all cheaper tiers in cascade.py and extract.py have missed. A single Browser Use
|
||||
agent session does both:
|
||||
1. Navigate to the company website and locate the careers/jobs page.
|
||||
2. From that page, open one specific open-position URL.
|
||||
|
||||
Both URLs are returned in one call — never two browser sessions for the same company.
|
||||
A module-level memo keyed by normalized host ensures a same-domain second call (e.g.
|
||||
cascade passes the root website, extract passes the careers URL on the same host) is a
|
||||
cache hit with no new browser launch.
|
||||
|
||||
Graceful degradation:
|
||||
- ENABLE_BROWSER_AGENT=false → log INFO, return None
|
||||
- Placeholder AGENT_MODEL/LLM key → log INFO, return None
|
||||
- browser_use import error → log WARNING, return None
|
||||
- Chromium/navigation/agent error → log WARNING, return None
|
||||
In all cases the batch still completes; those records land in needs_review.
|
||||
"""
|
||||
# TODO (Stage 2/3 last resort): implement per CLAUDE.md "Stage 2 — tier 6" and "Stage 3 — tier 5".
|
||||
# This is the LAST tier of the cascade. Fires only when all cheaper tiers in cascade.py
|
||||
# and extract.py have failed. One Browser Use agent session does both:
|
||||
# 1. Navigate to the company website and locate the careers/jobs page.
|
||||
# 2. From the careers page, return the URL of one open position.
|
||||
# Graceful degradation: if Browser Use / Playwright / LLM key are unavailable, log clearly
|
||||
# and return (careers_url=None, position_url=None) so the pipeline records needs_review.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentFallbackResult(BaseModel):
|
||||
"""Public result of find_and_extract()."""
|
||||
|
||||
careers_url: str | None = None
|
||||
position_url: str | None = None
|
||||
|
||||
|
||||
class _AgentOutput(BaseModel):
|
||||
"""Structured-output schema handed to the Browser Use Agent."""
|
||||
|
||||
careers_url: str | None = None
|
||||
position_url: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level memo — one entry per normalized host
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MEMO: dict[str, AgentFallbackResult | None] = {}
|
||||
|
||||
# Max browser-agent steps before timing out.
|
||||
_MAX_STEPS = 30
|
||||
|
||||
# Task prompt for the fused Stage-2 + Stage-3 session.
|
||||
_TASK_TEMPLATE = (
|
||||
"You are helping a job-sourcing pipeline. Given a company website URL, do the following "
|
||||
"in a single browsing session:\n"
|
||||
"1. Navigate to {website}\n"
|
||||
"2. Find the company's official careers or jobs page (the page that lists open positions). "
|
||||
" Explore navigation menus, footer links, or search for '/careers', '/jobs', etc.\n"
|
||||
"3. Once on the careers page, open one specific open-position detail page "
|
||||
" (a page for exactly one role — not a listing index, filter, or category page).\n"
|
||||
"4. Return both URLs in the structured output fields:\n"
|
||||
" - careers_url: the URL of the careers/jobs listing page\n"
|
||||
" - position_url: the URL of one specific open-position detail page\n"
|
||||
"If you cannot find a careers page or any open positions, set the corresponding field to null."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _agent_unavailable() -> bool:
|
||||
"""Return True when the browser agent is not configured (placeholder values)."""
|
||||
s = get_settings()
|
||||
return (
|
||||
s.agent_model.startswith("PLACEHOLDER")
|
||||
or s.llm_api_key.startswith("PLACEHOLDER")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _site_key(url: str) -> str:
|
||||
"""Normalized netloc for memo keying — strips 'www.', lowercased."""
|
||||
host = urlsplit(url).netloc.lower()
|
||||
return host.removeprefix("www.")
|
||||
|
||||
|
||||
def _find_chromium_executable() -> str | None:
|
||||
"""Return the Playwright-installed Chromium binary path, or None if not found.
|
||||
|
||||
browser-use 0.8.0's LocalBrowserWatchdog only globs `chrome-linux/chrome` but
|
||||
playwright ≥1.40 installs to `chrome-linux64/chrome`. Passing executable_path
|
||||
explicitly bypasses the watchdog's uvx-based auto-install fallback.
|
||||
"""
|
||||
pw_base = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
|
||||
if not pw_base:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
pw_base = str(pathlib.Path.home() / "Library" / "Caches" / "ms-playwright")
|
||||
elif system == "Windows":
|
||||
pw_base = str(pathlib.Path.home() / "AppData" / "Local" / "ms-playwright")
|
||||
else:
|
||||
pw_base = str(pathlib.Path.home() / ".cache" / "ms-playwright")
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
patterns = [
|
||||
f"{pw_base}/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium",
|
||||
f"{pw_base}/chromium-*/chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium",
|
||||
]
|
||||
elif system == "Windows":
|
||||
patterns = [
|
||||
f"{pw_base}\\chromium-*\\chrome-win\\chrome.exe",
|
||||
f"{pw_base}\\chromium-*\\chrome-win64\\chrome.exe",
|
||||
]
|
||||
else:
|
||||
patterns = [
|
||||
f"{pw_base}/chromium-*/chrome-linux64/chrome",
|
||||
f"{pw_base}/chromium-*/chrome-linux/chrome",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if matches:
|
||||
return matches[-1] # newest version last alphabetically
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_chat_model(): # type: ignore[return]
|
||||
"""Construct the chat-model instance for the Browser Use agent.
|
||||
|
||||
Test seam — monkeypatch this to inject a fake without touching Browser Use.
|
||||
Lazy-imports browser_use so a missing package never crashes module import.
|
||||
|
||||
agent_model format: "provider:model_id" (e.g. "anthropic:claude-sonnet-4-6").
|
||||
Unknown/absent provider prefix defaults to ChatOpenAI.
|
||||
"""
|
||||
from browser_use import ChatAnthropic, ChatOpenAI # lazy import
|
||||
|
||||
s = get_settings()
|
||||
model_str: str = s.agent_model
|
||||
api_key: str | None = None if s.llm_api_key.startswith("PLACEHOLDER") else s.llm_api_key
|
||||
|
||||
if ":" in model_str:
|
||||
provider, model_id = model_str.split(":", 1)
|
||||
else:
|
||||
provider, model_id = "openai", model_str
|
||||
|
||||
if provider == "anthropic":
|
||||
kwargs = {"model": model_id}
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
return ChatAnthropic(**kwargs)
|
||||
else:
|
||||
kwargs = {"model": model_id}
|
||||
if api_key:
|
||||
kwargs["openai_api_key"] = api_key
|
||||
return ChatOpenAI(**kwargs)
|
||||
|
||||
|
||||
async def _agent_session(website: str, chat) -> _AgentOutput | None: # type: ignore[return]
|
||||
"""Run a single Browser Use session; return structured output or None."""
|
||||
from browser_use import Agent, Browser # lazy import
|
||||
|
||||
s = get_settings()
|
||||
# Pass executable_path explicitly so browser-use skips its uvx-based auto-installer.
|
||||
# browser-use 0.8.0's LocalBrowserWatchdog globs chrome-linux/chrome but playwright
|
||||
# ≥1.40 installs to chrome-linux64/chrome — the two paths diverge and the watchdog
|
||||
# falls through to `uvx playwright install` even when chromium is present.
|
||||
chromium_exe = _find_chromium_executable()
|
||||
browser = Browser(headless=s.browser_headless, executable_path=chromium_exe)
|
||||
task = _TASK_TEMPLATE.format(website=website)
|
||||
agent = Agent(
|
||||
task=task,
|
||||
llm=chat,
|
||||
browser=browser,
|
||||
output_model_schema=_AgentOutput,
|
||||
)
|
||||
try:
|
||||
history = await agent.run(max_steps=_MAX_STEPS)
|
||||
out = history.structured_output
|
||||
if isinstance(out, _AgentOutput):
|
||||
return out
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_blocking(coro_factory) -> _AgentOutput | None: # type: ignore[return]
|
||||
"""Run an async coroutine from synchronous code.
|
||||
|
||||
Uses asyncio.run() when no event loop is running (the normal CLI path).
|
||||
Falls back to a one-shot ThreadPoolExecutor when called from inside an
|
||||
already-running loop (e.g. an async Prefect flow).
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Already inside a running loop — offload to a thread.
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, coro_factory())
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
# No running loop — safe to call asyncio.run() directly.
|
||||
return asyncio.run(coro_factory())
|
||||
|
||||
|
||||
def _valid_url(val: str | None) -> str | None:
|
||||
"""Return val if it's an http(s) URL, else None."""
|
||||
if val and isinstance(val, str) and val.startswith(("http://", "https://")):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_and_extract(website: str) -> AgentFallbackResult | None:
|
||||
"""Fused Stage-2 + Stage-3 fallback: browser agent finds careers page AND one job URL.
|
||||
|
||||
Returns an AgentFallbackResult on success, or None on any graceful failure.
|
||||
Never raises — all errors are logged and swallowed so the pipeline continues.
|
||||
Results are memoized per normalized host so a same-domain second call is free.
|
||||
"""
|
||||
s = get_settings()
|
||||
|
||||
if not s.enable_browser_agent:
|
||||
logger.info("agent_fallback(%s): ENABLE_BROWSER_AGENT=false, skipping", website)
|
||||
return None
|
||||
|
||||
if _agent_unavailable():
|
||||
logger.info(
|
||||
"agent_fallback(%s): agent_model or llm_api_key not configured, skipping", website
|
||||
)
|
||||
return None
|
||||
|
||||
key = _site_key(website)
|
||||
if key in _MEMO:
|
||||
logger.info("agent_fallback(%s): memo hit, returning cached result", website)
|
||||
return _MEMO[key]
|
||||
|
||||
# Build the chat model (lazy import — handles missing browser_use gracefully).
|
||||
try:
|
||||
chat = _build_chat_model()
|
||||
except Exception as exc:
|
||||
logger.warning("agent_fallback(%s): failed to build chat model: %s", website, exc)
|
||||
_MEMO[key] = None
|
||||
return None
|
||||
|
||||
# Run the fused browser session.
|
||||
try:
|
||||
raw: _AgentOutput | None = _run_blocking(lambda: _agent_session(website, chat))
|
||||
except Exception as exc:
|
||||
logger.warning("agent_fallback(%s): agent session error: %s", website, exc)
|
||||
_MEMO[key] = None
|
||||
return None
|
||||
|
||||
if raw is None:
|
||||
logger.info("agent_fallback(%s): agent returned no structured output", website)
|
||||
_MEMO[key] = None
|
||||
return None
|
||||
|
||||
result = AgentFallbackResult(
|
||||
careers_url=_valid_url(raw.careers_url),
|
||||
position_url=_valid_url(raw.position_url),
|
||||
)
|
||||
|
||||
if result.careers_url or result.position_url:
|
||||
logger.info(
|
||||
"agent_fallback(%s): careers_url=%s position_url=%s",
|
||||
website, result.careers_url, result.position_url,
|
||||
)
|
||||
else:
|
||||
logger.info("agent_fallback(%s): agent found no usable URLs", website)
|
||||
|
||||
_MEMO[key] = result
|
||||
return result
|
||||
|
||||
@@ -7,10 +7,11 @@ Cascade order (return early on first success):
|
||||
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)
|
||||
6. Browser agent → agent_fallback.find_and_extract() confidence 0.50
|
||||
|
||||
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).
|
||||
the ATS tier or browser-agent tier resolves — the first open-position URL for
|
||||
free (Stage-3 shortcut).
|
||||
|
||||
The optional *client* parameter follows the managed-client pattern from
|
||||
resolve.py: supply an existing httpx.Client to reuse connections; otherwise a
|
||||
@@ -23,7 +24,9 @@ import logging
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..http import build_client, request_with_retries
|
||||
from .. import agent_fallback as _agent_fallback
|
||||
from . import ats as _ats
|
||||
from . import classify_llm as _classify_llm
|
||||
from . import heuristics as _heuristics
|
||||
@@ -41,7 +44,7 @@ class CareersResult(BaseModel):
|
||||
|
||||
careers_url: str | None = None
|
||||
confidence: float = 0.0
|
||||
# method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "llm_classify" | "none"
|
||||
# method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "llm_classify" | "browser_agent" | "none"
|
||||
method: str = "none"
|
||||
ats_name: str | None = None
|
||||
# Free Stage-3 shortcut: populated when ATS tier resolves (first open job URL).
|
||||
@@ -163,6 +166,26 @@ def find_careers_page(
|
||||
except Exception as exc:
|
||||
logger.warning("cascade(%s): llm_classify tier error: %s", website, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 6 — Browser agent (fused Stage-2 + Stage-3 fallback)
|
||||
# ------------------------------------------------------------------
|
||||
if get_settings().enable_browser_agent:
|
||||
try:
|
||||
ag = _agent_fallback.find_and_extract(website)
|
||||
if ag and ag.careers_url:
|
||||
logger.info(
|
||||
"cascade(%s): resolved via browser_agent careers_url=%s",
|
||||
website, ag.careers_url,
|
||||
)
|
||||
return CareersResult(
|
||||
careers_url=ag.careers_url,
|
||||
confidence=0.50,
|
||||
method="browser_agent",
|
||||
position_url=ag.position_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("cascade(%s): browser_agent tier error: %s", website, exc)
|
||||
|
||||
logger.info("cascade(%s): all tiers missed", website)
|
||||
return CareersResult()
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ Cascade order (return early on first hit):
|
||||
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").
|
||||
5. Browser agent — agent_fallback.find_and_extract(); fused with Stage-2 so
|
||||
the same memoized session covers both stages.
|
||||
6. 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"
|
||||
"ats:{name}" | "json_ld" | "anchor" | "llm_classify" | "browser_agent" | "none"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,8 +28,10 @@ from urllib.parse import urlsplit
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from . import agent_fallback as _agent_fallback
|
||||
from .careers import ats as _ats
|
||||
from .careers.classify_llm import classify_job_link, extract_anchors
|
||||
from .config import get_settings
|
||||
from .http import build_client, request_with_retries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -206,6 +210,20 @@ def extract_open_position(
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): llm_classify tier error: %s", careers_url, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 5 — Browser agent (fused; memo-shared with cascade Tier 6)
|
||||
# ------------------------------------------------------------------
|
||||
if get_settings().enable_browser_agent:
|
||||
try:
|
||||
ag = _agent_fallback.find_and_extract(careers_url)
|
||||
if ag and ag.position_url:
|
||||
logger.info(
|
||||
"extract(%s): browser_agent pos=%s", careers_url, ag.position_url
|
||||
)
|
||||
return ag.position_url, "browser_agent"
|
||||
except Exception as exc:
|
||||
logger.warning("extract(%s): browser_agent tier error: %s", careers_url, exc)
|
||||
|
||||
logger.info("extract(%s): all tiers missed", careers_url)
|
||||
return None, "none"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user