Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ResumeExtractRequest(BaseModel):
contact_id: str
attachment_id: str
filename: str
refresh_token: str | None = None


class ResumeApplyRequest(BaseModel):
Expand Down Expand Up @@ -623,6 +624,8 @@ async def resume_extract_handler(request: Request) -> JSONResponse:
f"resume-extract:{payload.contact_id}:{payload.attachment_id}:"
f"{settings.resume_extractor_version}:{model_name}"
)
if payload.refresh_token:
idempotency_key = f"{idempotency_key}:{payload.refresh_token}"
job = await asyncio.to_thread(
enqueue_job,
queue=queue,
Expand Down
12 changes: 11 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from datetime import date, datetime, timezone
import re
from typing import Any, Literal
from uuid import uuid4

import aiohttp
import discord
Expand Down Expand Up @@ -2282,13 +2283,20 @@ def _backend_url(self, path: str) -> str:
return f"{settings.backend_api_base_url.rstrip('/')}{path}"

async def _enqueue_resume_extract_job(
self, *, contact_id: str, attachment_id: str, filename: str
self,
*,
contact_id: str,
attachment_id: str,
filename: str,
refresh_token: str | None = None,
) -> str:
payload = {
"contact_id": contact_id,
"attachment_id": attachment_id,
"filename": filename,
}
if refresh_token:
payload["refresh_token"] = refresh_token
data = await self._backend_request_json(
"POST",
"/jobs/resume-extract",
Expand Down Expand Up @@ -3059,11 +3067,13 @@ async def _run_resume_extract_and_preview(
status_text = (
status_message or "📥 Resume uploaded. Extracting profile fields now..."
)
refresh_token = uuid4().hex if action_name == "crm.reprocess_resume" else None
try:
job_id = await self._enqueue_resume_extract_job(
contact_id=contact_id,
attachment_id=attachment_id,
filename=filename,
refresh_token=refresh_token,
)
except Exception as exc:
logger.error("Failed to enqueue resume extract job: %s", exc)
Expand Down
7 changes: 4 additions & 3 deletions packages/shared/src/five08/resume_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@ def _parse_location_candidate(
"",
candidate,
).strip(" -,:")
candidate = re.sub(r"^[\s*•·○●◦▪-]+", "", candidate).strip(" -,:")
candidate = re.sub(r"(?i)^(?:remote|hybrid|onsite)\s*[-,:]?\s*", "", candidate)
candidate = re.sub(r"\b\d{5}(?:-\d{4})?\b", "", candidate).strip(" ,")
if not candidate:
Expand Down Expand Up @@ -1007,7 +1008,7 @@ def _parse_location_candidate(

def _candidate_location_fragments(line: str) -> list[str]:
fragments = [line.strip()]
for fragment in re.split(r"[|•·]", line):
for fragment in re.split(r"[|•·○●◦▪]", line):
cleaned = fragment.strip()
if cleaned and cleaned not in fragments:
fragments.append(cleaned)
Expand Down Expand Up @@ -2621,9 +2622,9 @@ def _extract_header_location(
continue
if "remote" in lowered:
continue
if len(line) > 80:
continue
for candidate in _candidate_location_fragments(line):
if len(candidate) > 80:
continue
Comment on lines 2623 to +2627

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_extract_header_location skips any header line containing the substring "remote" before attempting to parse bullet-separated candidates. With the new bullet/fragment parsing, a line like "Toronto, Ontario ○ remote" would now be skipped entirely even though a valid location fragment exists; consider moving the "remote" filter to the per-candidate loop (or only skipping when the whole line is just a remote marker) so trailing OCR bullet text doesn’t suppress a valid location.

Copilot uses AI. Check for mistakes.
parsed = _parse_location_candidate(candidate)
if parsed:
return parsed
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_backend_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,40 @@ def test_resume_extract_handler_enqueues_job(
assert call_kwargs["idempotency_key"] == "resume-extract:c-1:a-1:v7:gpt-test"


def test_resume_extract_handler_appends_refresh_token_to_idempotency_key(
monkeypatch: pytest.MonkeyPatch,
client: TestClient,
auth_headers: dict[str, str],
) -> None:
"""Explicit refresh tokens should force a new resume extract job key."""
monkeypatch.setattr(api.settings, "resume_extractor_version", "v7")
monkeypatch.setattr(api.settings, "openai_api_key", "key")
monkeypatch.setattr(api.settings, "openai_base_url", None)
monkeypatch.setattr(api.settings, "resume_ai_model", "gpt-test")

with patch("five08.backend.api.enqueue_job") as mock_enqueue:
mock_enqueue.return_value = Mock(id="job-extract", created=True)
response = client.post(
"/jobs/resume-extract",
json={
"contact_id": "c-1",
"attachment_id": "a-1",
"filename": "resume.pdf",
"refresh_token": "refresh-123",
},
headers=auth_headers,
)

payload = response.json()
assert response.status_code == 202
assert payload["job_id"] == "job-extract"
call_kwargs = mock_enqueue.call_args.kwargs
assert (
call_kwargs["idempotency_key"]
== "resume-extract:c-1:a-1:v7:gpt-test:refresh-123"
)


def test_resume_apply_handler_enqueues_job(
client: TestClient,
auth_headers: dict[str, str],
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4914,6 +4914,39 @@ async def test_reprocess_confirmation_view_calls_reprocess_preview(
== "🔄 Reprocessing resume and extracting profile fields now..."
)

@pytest.mark.asyncio
async def test_run_resume_extract_and_preview_uses_refresh_token_for_reprocess(
self, crm_cog, mock_interaction
):
"""Explicit reprocess actions should bypass cached extract jobs."""
crm_cog._enqueue_resume_extract_job = AsyncMock(return_value="job-123")
crm_cog._wait_for_backend_job_result = AsyncMock(
return_value={
"status": "succeeded",
"result": {"success": False, "error": "boom"},
}
)
crm_cog._build_resume_extract_debug_file = Mock(return_value=Mock())
crm_cog._audit_command = Mock()

with patch(
"five08.discord_bot.cogs.crm.uuid4",
return_value=Mock(hex="refresh-token-123"),
):
Comment on lines +4932 to +4935

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, the patched uuid4().hex value includes hyphens ("refresh-token-123"), but uuid4().hex in production is a 32-character hex string without hyphens. Using a realistic hex-like token here would make the test better reflect real behavior (while still asserting that the value is forwarded).

Copilot uses AI. Check for mistakes.
await crm_cog._run_resume_extract_and_preview(
mock_interaction,
contact_id="contact123",
contact_name="Candidate User",
attachment_id="resume123",
filename="candidate.pdf",
link_member=None,
action="crm.reprocess_resume",
status_message="🔄 Reprocessing resume and extracting profile fields now...",
)

kwargs = crm_cog._enqueue_resume_extract_job.await_args.kwargs
assert kwargs["refresh_token"] == "refresh-token-123"

@pytest.mark.asyncio
async def test_build_match_candidates_posting_fetches_jd_links_from_text(
self, jobs_cog
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/test_resume_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from unittest.mock import Mock, patch

import pytest

from five08.resume_extractor import _coerce_email_list
from five08.resume_extractor import _infer_timezone_from_location
from five08.resume_extractor import _normalize_name_part
Expand Down Expand Up @@ -1130,6 +1132,39 @@ def test_extract_header_location_supports_city_region_without_country() -> None:
assert country is None


def test_extract_header_location_ignores_unicode_bullet_trailing_text() -> None:
"""Header parsing should recover location before OCR-style bullet text."""
city, state, country = ResumeProfileExtractor._extract_header_location(
"Jane Doe\n"
"Toronto, Ontario ○ A Python Django API handles account creation and management, and applies\n"
"jane@example.com"
)

assert city == "Toronto"
assert state == "Ontario"
assert country is None
Comment thread
michaelmwu marked this conversation as resolved.


@pytest.mark.parametrize(
"header_line",
[
"• Location: Toronto, Ontario",
"○ based in Toronto, Ontario",
],
)
def test_extract_header_location_supports_leading_bullets_and_prefixes(
header_line: str,
) -> None:
"""Header parsing should handle leading bullets and location prefixes."""
city, state, country = ResumeProfileExtractor._extract_header_location(
f"Jane Doe\n{header_line}\nAdditional OCR text\njane@example.com"
)

assert city == "Toronto"
assert state == "Ontario"
assert country is None


def test_extract_location_uses_current_role_location_when_header_missing() -> None:
"""Current-role location lines should backfill address fields and timezone."""
extractor = ResumeProfileExtractor(api_key=None)
Expand Down