From 50cc879e1e696b4dad451e182f9b0268d9ac71a9 Mon Sep 17 00:00:00 2001 From: nfel Date: Mon, 3 Aug 2026 00:57:19 +0330 Subject: [PATCH] Improve tailoring and job fetch resilience --- .env.example | 3 + README.md | 9 +++ src/resume_agent/agent.py | 11 +++ src/resume_agent/web.py | 158 +++++++++++++++++++++++++++++++++----- tests/test_agent.py | 41 ++++++++++ tests/test_web.py | 108 ++++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 tests/test_web.py diff --git a/.env.example b/.env.example index b3d29cf..7eb008d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1 RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models RESUME_AGENT_API_STYLE=chat RESUME_AGENT_LLM_TIMEOUT_SECONDS=1800 +RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS=120 +RESUME_AGENT_JOB_FETCH_RETRIES=2 +RESUME_AGENT_JOB_FETCH_RETRY_BACKOFF_SECONDS=1 RESUME_AGENT_LOG_LEVEL=INFO RESUME_AGENT_LOG_MODEL_PAYLOADS=true diff --git a/README.md b/README.md index 514cb71..0ffa087 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ RESUME_AGENT_API_KEY=your-rotated-token RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1 RESUME_AGENT_API_STYLE=chat RESUME_AGENT_LLM_TIMEOUT_SECONDS=1800 +RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS=120 +RESUME_AGENT_JOB_FETCH_RETRIES=2 ``` Discover the provider's exact model IDs, then add your choice to `.env`: @@ -87,6 +89,13 @@ RESUME_AGENT_LOG_MODEL_PAYLOADS=false to 1800 seconds (30 minutes), while connection establishment is capped at 30 seconds. An upstream gateway may still enforce its own shorter timeout. +Fetching a job URL is a separate network request made before the model runs. It defaults +to a 120-second timeout with two retries and exponential backoff. Configure it with +`RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS`, `RESUME_AGENT_JOB_FETCH_RETRIES`, and +`RESUME_AGENT_JOB_FETCH_RETRY_BACKOFF_SECONDS`. If a careers site blocks server-side +requests, paste the job description into the app instead; increasing the model timeout +will not affect that connection. + ## Usage ### Web interface diff --git a/src/resume_agent/agent.py b/src/resume_agent/agent.py index 55e34db..414bcaa 100644 --- a/src/resume_agent/agent.py +++ b/src/resume_agent/agent.py @@ -82,6 +82,7 @@ def tailor_resume( _tailoring_prompt(tailoring_strength, evidence_mode), json.dumps(payload, ensure_ascii=False), ) + _restore_canonical_contact(profile, draft) validate_package(profile, draft, require_evidence=evidence_mode == "strict") audit_payload = { @@ -95,6 +96,7 @@ def tailor_resume( _audit_prompt(evidence_mode), json.dumps(audit_payload, ensure_ascii=False), ) + _restore_canonical_contact(profile, audited) validate_package(profile, audited, require_evidence=evidence_mode == "strict") return audited @@ -183,6 +185,7 @@ def revise_tailored_resume( f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}", json.dumps(revision_payload, ensure_ascii=False), ) + _restore_canonical_contact(profile, revised) _validate_revision(profile, current, revised, evidence_mode) audit_payload = { @@ -197,6 +200,7 @@ def revise_tailored_resume( _audit_prompt(evidence_mode), json.dumps(audit_payload, ensure_ascii=False), ) + _restore_canonical_contact(profile, audited) _validate_revision(profile, current, audited, evidence_mode) return audited @@ -212,6 +216,13 @@ def _validate_revision( raise ValueError("A resume revision cannot change the original job analysis.") +def _restore_canonical_contact( + profile: CareerProfile, + package: TailoringPackage, +) -> None: + package.resume.contact = profile.contact.model_copy(deep=True) + + def validate_profile(profile: CareerProfile) -> None: ids = [fact.id for fact in profile.facts] if not ids: diff --git a/src/resume_agent/web.py b/src/resume_agent/web.py index f2835dd..a9bb895 100644 --- a/src/resume_agent/web.py +++ b/src/resume_agent/web.py @@ -1,7 +1,10 @@ from __future__ import annotations import ipaddress +import logging +import os import socket +import time from urllib.parse import urljoin, urlparse import httpx @@ -9,6 +12,11 @@ from bs4 import BeautifulSoup MAX_JOB_PAGE_BYTES = 2 * 1024 * 1024 MAX_REDIRECTS = 5 +DEFAULT_JOB_FETCH_TIMEOUT_SECONDS = 120.0 +DEFAULT_JOB_FETCH_CONNECT_TIMEOUT_SECONDS = 60.0 +DEFAULT_JOB_FETCH_RETRIES = 2 +DEFAULT_JOB_FETCH_RETRY_BACKOFF_SECONDS = 1.0 +LOGGER = logging.getLogger("resume_agent.web") class JobPageError(ValueError): @@ -46,27 +54,139 @@ def html_to_text(html: str) -> str: return text -def fetch_job_page(url: str) -> str: +def _positive_float_env(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError as exc: + raise JobPageError(f"{name} must be a number.") from exc + if value <= 0: + raise JobPageError(f"{name} must be greater than zero.") + return value + + +def _nonnegative_int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(raw) + except ValueError as exc: + raise JobPageError(f"{name} must be an integer.") from exc + if value < 0: + raise JobPageError(f"{name} must be zero or greater.") + return value + + +def fetch_job_page( + url: str, + *, + timeout_seconds: float | None = None, + retries: int | None = None, + retry_backoff_seconds: float | None = None, +) -> str: + timeout_seconds = ( + timeout_seconds + if timeout_seconds is not None + else _positive_float_env( + "RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS", + DEFAULT_JOB_FETCH_TIMEOUT_SECONDS, + ) + ) + retries = ( + retries + if retries is not None + else _nonnegative_int_env("RESUME_AGENT_JOB_FETCH_RETRIES", DEFAULT_JOB_FETCH_RETRIES) + ) + retry_backoff_seconds = ( + retry_backoff_seconds + if retry_backoff_seconds is not None + else _positive_float_env( + "RESUME_AGENT_JOB_FETCH_RETRY_BACKOFF_SECONDS", + DEFAULT_JOB_FETCH_RETRY_BACKOFF_SECONDS, + ) + ) + if timeout_seconds <= 0: + raise JobPageError("Job fetch timeout must be greater than zero.") + if retries < 0: + raise JobPageError("Job fetch retries must be zero or greater.") + if retry_backoff_seconds < 0: + raise JobPageError("Job fetch retry backoff must be zero or greater.") + current = url headers = {"User-Agent": "ResumeAgent/0.1 (+local CLI)"} - with httpx.Client(timeout=15, headers=headers, follow_redirects=False) as client: + timeout = httpx.Timeout( + timeout=timeout_seconds, + connect=min(DEFAULT_JOB_FETCH_CONNECT_TIMEOUT_SECONDS, timeout_seconds), + ) + with httpx.Client(timeout=timeout, headers=headers, follow_redirects=False) as client: for _ in range(MAX_REDIRECTS + 1): _validate_public_url(current) - with client.stream("GET", current) as response: - if response.is_redirect: - location = response.headers.get("location") - if not location: - raise JobPageError("Job page returned an invalid redirect.") - current = urljoin(current, location) - continue - response.raise_for_status() - content_type = response.headers.get("content-type", "") - if "text/html" not in content_type and "text/plain" not in content_type: - raise JobPageError("Job URL did not return HTML or plain text.") - body = bytearray() - for chunk in response.iter_bytes(): - body.extend(chunk) - if len(body) > MAX_JOB_PAGE_BYTES: - raise JobPageError("Job page is larger than the 2 MB safety limit.") - return html_to_text(body.decode(response.encoding or "utf-8", errors="replace")) + for attempt in range(retries + 1): + try: + with client.stream("GET", current) as response: + retryable_status = response.status_code in {408, 425, 429} or ( + 500 <= response.status_code <= 599 + ) + if retryable_status and attempt < retries: + LOGGER.warning( + "event=job_fetch_retry url_host=%s attempt=%d/%d status=%d", + urlparse(current).hostname, + attempt + 1, + retries + 1, + response.status_code, + ) + elif response.is_redirect: + location = response.headers.get("location") + if not location: + raise JobPageError("Job page returned an invalid redirect.") + current = urljoin(current, location) + break + else: + response.raise_for_status() + content_type = response.headers.get("content-type", "") + if ( + "text/html" not in content_type + and "text/plain" not in content_type + ): + raise JobPageError( + "Job URL did not return HTML or plain text." + ) + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > MAX_JOB_PAGE_BYTES: + raise JobPageError( + "Job page is larger than the 2 MB safety limit." + ) + return html_to_text( + body.decode(response.encoding or "utf-8", errors="replace") + ) + except httpx.HTTPStatusError as exc: + raise JobPageError( + f"Job URL returned HTTP {exc.response.status_code}." + ) from exc + except httpx.RequestError as exc: + if attempt >= retries: + raise JobPageError( + "Could not connect to the job URL after " + f"{retries + 1} attempts ({type(exc).__name__}: {exc}). " + "The site may block server requests; paste the job description " + "into the app instead." + ) from exc + LOGGER.warning( + "event=job_fetch_retry url_host=%s attempt=%d/%d error_type=%s", + urlparse(current).hostname, + attempt + 1, + retries + 1, + type(exc).__name__, + ) + + delay = retry_backoff_seconds * (2**attempt) + if delay: + time.sleep(delay) + else: + raise JobPageError("Job URL could not be fetched.") raise JobPageError("Job URL redirected too many times.") diff --git a/tests/test_agent.py b/tests/test_agent.py index c5dd920..7ce954c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -123,6 +123,47 @@ def test_tailor_runs_draft_and_audit() -> None: assert result == expected +def test_tailor_restores_canonical_contact_after_each_model_pass() -> None: + draft = package() + audited = package() + for result in (draft, audited): + result.resume.contact = ContactInfo( + full_name="Reformatted Name", + email="changed@example.com", + phone="000", + location="Changed location", + linkedin="https://example.com/changed", + website=None, + ) + + result = tailor_resume(FakeLLM([draft, audited]), profile(), "long job post") + + assert result.resume.contact == CONTACT + + +def test_revision_restores_canonical_contact_after_each_model_pass() -> None: + revised = package() + audited = package() + for result in (revised, audited): + result.resume.contact = ContactInfo( + full_name="Reformatted Name", + email="changed@example.com", + phone=None, + location="London", + linkedin=None, + website=None, + ) + + result = revise_tailored_resume( + FakeLLM([revised, audited]), + profile(), + package(), + "Make the summary more direct.", + ) + + assert result.resume.contact == CONTACT + + def test_tailoring_strength_controls_rewriting_without_relaxing_evidence() -> None: expected = package() llm = FakeLLM([expected, expected]) diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..9424b91 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from resume_agent import web + + +def _mock_client( + monkeypatch: pytest.MonkeyPatch, + handler: Any, + captured: dict[str, Any] | None = None, +) -> None: + original_client = httpx.Client + transport = httpx.MockTransport(handler) + + def client_factory(**kwargs: Any) -> httpx.Client: + if captured is not None: + captured.update(kwargs) + return original_client(transport=transport, **kwargs) + + monkeypatch.setattr(web.httpx, "Client", client_factory) + monkeypatch.setattr(web, "_validate_public_url", lambda _url: None) + + +def test_job_fetch_retries_transient_connection_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise httpx.ConnectTimeout("timed out", request=request) + return httpx.Response( + 200, + headers={"content-type": "text/html; charset=utf-8"}, + text="

Backend Engineer

", + ) + + _mock_client(monkeypatch, handler) + + result = web.fetch_job_page( + "https://jobs.example/role", + timeout_seconds=120, + retries=1, + retry_backoff_seconds=0, + ) + + assert result == "Backend Engineer" + assert attempts == 2 + + +def test_job_fetch_reports_timeout_as_job_page_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ConnectTimeout("timed out", request=request) + + _mock_client(monkeypatch, handler) + + with pytest.raises(web.JobPageError, match="after 3 attempts"): + web.fetch_job_page( + "https://jobs.example/role", + timeout_seconds=120, + retries=2, + retry_backoff_seconds=0, + ) + + assert attempts == 3 + + +def test_job_fetch_timeout_is_configurable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/plain"}, + text="Complete job description", + ) + + _mock_client(monkeypatch, handler, captured) + monkeypatch.setenv("RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS", "240") + monkeypatch.setenv("RESUME_AGENT_JOB_FETCH_RETRIES", "0") + + assert web.fetch_job_page("https://jobs.example/role") == "Complete job description" + timeout = captured["timeout"] + assert timeout.connect == 60 + assert timeout.read == 240 + + +def test_job_fetch_rejects_invalid_timeout_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("RESUME_AGENT_JOB_FETCH_TIMEOUT_SECONDS", "not-a-number") + + with pytest.raises(web.JobPageError, match="must be a number"): + web.fetch_job_page("https://jobs.example/role")