diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 36f6f411..9ee2339e 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -81,6 +81,7 @@ class ResumeExtractRequest(BaseModel): contact_id: str attachment_id: str filename: str + refresh_token: str | None = None class ResumeApplyRequest(BaseModel): @@ -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, diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index a35dbac8..0665d255 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -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 @@ -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", @@ -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) diff --git a/packages/shared/src/five08/resume_extractor.py b/packages/shared/src/five08/resume_extractor.py index 73c94c2f..2770b370 100644 --- a/packages/shared/src/five08/resume_extractor.py +++ b/packages/shared/src/five08/resume_extractor.py @@ -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: @@ -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) @@ -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 parsed = _parse_location_candidate(candidate) if parsed: return parsed diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index dcb733f3..e5f4c401 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -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], diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 2492679e..80e5f6f9 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -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"), + ): + 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 diff --git a/tests/unit/test_resume_extractor.py b/tests/unit/test_resume_extractor.py index 7202aac8..35eec419 100644 --- a/tests/unit/test_resume_extractor.py +++ b/tests/unit/test_resume_extractor.py @@ -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 @@ -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 + + +@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)