Improve tailoring and job fetch resilience
This commit is contained in:
@@ -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_MODEL=replace-with-a-model-id-from-resume-agent-models
|
||||||
RESUME_AGENT_API_STYLE=chat
|
RESUME_AGENT_API_STYLE=chat
|
||||||
RESUME_AGENT_LLM_TIMEOUT_SECONDS=1800
|
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_LEVEL=INFO
|
||||||
RESUME_AGENT_LOG_MODEL_PAYLOADS=true
|
RESUME_AGENT_LOG_MODEL_PAYLOADS=true
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ RESUME_AGENT_API_KEY=your-rotated-token
|
|||||||
RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1
|
RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1
|
||||||
RESUME_AGENT_API_STYLE=chat
|
RESUME_AGENT_API_STYLE=chat
|
||||||
RESUME_AGENT_LLM_TIMEOUT_SECONDS=1800
|
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`:
|
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.
|
to 1800 seconds (30 minutes), while connection establishment is capped at 30 seconds.
|
||||||
An upstream gateway may still enforce its own shorter timeout.
|
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
|
## Usage
|
||||||
|
|
||||||
### Web interface
|
### Web interface
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def tailor_resume(
|
|||||||
_tailoring_prompt(tailoring_strength, evidence_mode),
|
_tailoring_prompt(tailoring_strength, evidence_mode),
|
||||||
json.dumps(payload, ensure_ascii=False),
|
json.dumps(payload, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
_restore_canonical_contact(profile, draft)
|
||||||
validate_package(profile, draft, require_evidence=evidence_mode == "strict")
|
validate_package(profile, draft, require_evidence=evidence_mode == "strict")
|
||||||
|
|
||||||
audit_payload = {
|
audit_payload = {
|
||||||
@@ -95,6 +96,7 @@ def tailor_resume(
|
|||||||
_audit_prompt(evidence_mode),
|
_audit_prompt(evidence_mode),
|
||||||
json.dumps(audit_payload, ensure_ascii=False),
|
json.dumps(audit_payload, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
_restore_canonical_contact(profile, audited)
|
||||||
validate_package(profile, audited, require_evidence=evidence_mode == "strict")
|
validate_package(profile, audited, require_evidence=evidence_mode == "strict")
|
||||||
return audited
|
return audited
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ def revise_tailored_resume(
|
|||||||
f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}",
|
f"{REVISION_PROMPT}\n\n{_evidence_guidance(evidence_mode)}",
|
||||||
json.dumps(revision_payload, ensure_ascii=False),
|
json.dumps(revision_payload, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
_restore_canonical_contact(profile, revised)
|
||||||
_validate_revision(profile, current, revised, evidence_mode)
|
_validate_revision(profile, current, revised, evidence_mode)
|
||||||
|
|
||||||
audit_payload = {
|
audit_payload = {
|
||||||
@@ -197,6 +200,7 @@ def revise_tailored_resume(
|
|||||||
_audit_prompt(evidence_mode),
|
_audit_prompt(evidence_mode),
|
||||||
json.dumps(audit_payload, ensure_ascii=False),
|
json.dumps(audit_payload, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
_restore_canonical_contact(profile, audited)
|
||||||
_validate_revision(profile, current, audited, evidence_mode)
|
_validate_revision(profile, current, audited, evidence_mode)
|
||||||
return audited
|
return audited
|
||||||
|
|
||||||
@@ -212,6 +216,13 @@ def _validate_revision(
|
|||||||
raise ValueError("A resume revision cannot change the original job analysis.")
|
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:
|
def validate_profile(profile: CareerProfile) -> None:
|
||||||
ids = [fact.id for fact in profile.facts]
|
ids = [fact.id for fact in profile.facts]
|
||||||
if not ids:
|
if not ids:
|
||||||
|
|||||||
+139
-19
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -9,6 +12,11 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
MAX_JOB_PAGE_BYTES = 2 * 1024 * 1024
|
MAX_JOB_PAGE_BYTES = 2 * 1024 * 1024
|
||||||
MAX_REDIRECTS = 5
|
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):
|
class JobPageError(ValueError):
|
||||||
@@ -46,27 +54,139 @@ def html_to_text(html: str) -> str:
|
|||||||
return text
|
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
|
current = url
|
||||||
headers = {"User-Agent": "ResumeAgent/0.1 (+local CLI)"}
|
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):
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
_validate_public_url(current)
|
_validate_public_url(current)
|
||||||
with client.stream("GET", current) as response:
|
for attempt in range(retries + 1):
|
||||||
if response.is_redirect:
|
try:
|
||||||
location = response.headers.get("location")
|
with client.stream("GET", current) as response:
|
||||||
if not location:
|
retryable_status = response.status_code in {408, 425, 429} or (
|
||||||
raise JobPageError("Job page returned an invalid redirect.")
|
500 <= response.status_code <= 599
|
||||||
current = urljoin(current, location)
|
)
|
||||||
continue
|
if retryable_status and attempt < retries:
|
||||||
response.raise_for_status()
|
LOGGER.warning(
|
||||||
content_type = response.headers.get("content-type", "")
|
"event=job_fetch_retry url_host=%s attempt=%d/%d status=%d",
|
||||||
if "text/html" not in content_type and "text/plain" not in content_type:
|
urlparse(current).hostname,
|
||||||
raise JobPageError("Job URL did not return HTML or plain text.")
|
attempt + 1,
|
||||||
body = bytearray()
|
retries + 1,
|
||||||
for chunk in response.iter_bytes():
|
response.status_code,
|
||||||
body.extend(chunk)
|
)
|
||||||
if len(body) > MAX_JOB_PAGE_BYTES:
|
elif response.is_redirect:
|
||||||
raise JobPageError("Job page is larger than the 2 MB safety limit.")
|
location = response.headers.get("location")
|
||||||
return html_to_text(body.decode(response.encoding or "utf-8", errors="replace"))
|
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.")
|
raise JobPageError("Job URL redirected too many times.")
|
||||||
|
|||||||
@@ -123,6 +123,47 @@ def test_tailor_runs_draft_and_audit() -> None:
|
|||||||
assert result == expected
|
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:
|
def test_tailoring_strength_controls_rewriting_without_relaxing_evidence() -> None:
|
||||||
expected = package()
|
expected = package()
|
||||||
llm = FakeLLM([expected, expected])
|
llm = FakeLLM([expected, expected])
|
||||||
|
|||||||
@@ -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="<html><body><h1>Backend Engineer</h1></body></html>",
|
||||||
|
)
|
||||||
|
|
||||||
|
_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")
|
||||||
Reference in New Issue
Block a user