Improve tailoring and job fetch resilience

This commit is contained in:
2026-08-03 00:57:19 +03:30
parent 81d816ae54
commit 50cc879e1e
6 changed files with 311 additions and 19 deletions
+3
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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:
+128 -8
View File
@@ -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)
for attempt in range(retries + 1):
try:
with client.stream("GET", current) as response: with client.stream("GET", current) as response:
if response.is_redirect: 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") location = response.headers.get("location")
if not location: if not location:
raise JobPageError("Job page returned an invalid redirect.") raise JobPageError("Job page returned an invalid redirect.")
current = urljoin(current, location) current = urljoin(current, location)
continue break
else:
response.raise_for_status() response.raise_for_status()
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
if "text/html" not in content_type and "text/plain" not in content_type: if (
raise JobPageError("Job URL did not return HTML or plain text.") "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() body = bytearray()
for chunk in response.iter_bytes(): for chunk in response.iter_bytes():
body.extend(chunk) body.extend(chunk)
if len(body) > MAX_JOB_PAGE_BYTES: if len(body) > MAX_JOB_PAGE_BYTES:
raise JobPageError("Job page is larger than the 2 MB safety limit.") raise JobPageError(
return html_to_text(body.decode(response.encoding or "utf-8", errors="replace")) "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.")
+41
View File
@@ -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])
+108
View File
@@ -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")