226 lines
9.8 KiB
Python
226 lines
9.8 KiB
Python
import httpx
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from job.job_post.models import JobPosts
|
|
from talent import plugins
|
|
from talent.matching import annotate_applications
|
|
from talent.models import TalentProfiles, TalentRuns
|
|
from talent.serializers import (
|
|
serialize_talent_profile,
|
|
serialize_talent_profile_detail,
|
|
serialize_talent_run,
|
|
)
|
|
|
|
|
|
def _search_basis(actor_input: dict) -> dict:
|
|
"""The identity of a search, ignoring pagination and batch-size knobs."""
|
|
return {k: v for k, v in (actor_input or {}).items() if k not in ("startPage", "maxItems")}
|
|
|
|
|
|
class Talent:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def _get_job(self, job_post_id):
|
|
job = await JobPosts.get_job_post_by_id(self.session, job_post_id)
|
|
if not job or job.is_deleted:
|
|
raise HTTPException(status_code=404, detail="Job post not found")
|
|
return job
|
|
|
|
async def start_run(self, job_post_id, payload, current_user):
|
|
job = await self._get_job(job_post_id)
|
|
|
|
active = await TalentRuns.latest_active_run(self.session, job_post_id)
|
|
if active:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="A talent search is already running for this job",
|
|
)
|
|
|
|
requested = payload.get("max_results")
|
|
max_results = min(int(requested), plugins.APIFY_MAX_RESULTS) if requested else plugins.APIFY_MAX_RESULTS
|
|
if max_results < 1:
|
|
raise HTTPException(status_code=422, detail="max_results must be at least 1")
|
|
# Floor of 10 per paid run (user asked for at least 10 results a
|
|
# search) — unless the env cap itself is set lower.
|
|
max_results = max(max_results, min(10, plugins.APIFY_MAX_RESULTS))
|
|
|
|
overrides = {
|
|
"keywords": payload.get("keywords"),
|
|
"location": payload.get("location"),
|
|
}
|
|
job_fields = {
|
|
"title": job.title,
|
|
"requirements": job.requirements,
|
|
"optional_skills": job.optional_skills,
|
|
"location": job.location,
|
|
"experience_min": job.experience_min,
|
|
"experience_max": job.experience_max,
|
|
}
|
|
actor_input = plugins.build_actor_input(
|
|
job_fields, max_results=max_results, overrides=overrides
|
|
)
|
|
|
|
# Re-running the same search continues deeper into LinkedIn's result
|
|
# pages (25 profiles each), so every run surfaces new people. A changed
|
|
# query/location/experience is a different search and restarts at page 1.
|
|
basis = _search_basis(actor_input)
|
|
prior_runs, _ = await TalentRuns.fetch_runs(self.session, job_post_id=job_post_id)
|
|
prior_pages = [
|
|
int((r.search_input or {}).get("startPage") or 1)
|
|
for r in prior_runs
|
|
if r.status == "succeeded" and _search_basis(r.search_input) == basis
|
|
]
|
|
if prior_pages:
|
|
actor_input = plugins.build_actor_input(
|
|
job_fields,
|
|
max_results=max_results,
|
|
overrides=overrides,
|
|
start_page=max(prior_pages) + 1,
|
|
)
|
|
run = await TalentRuns.insert_run(self.session, {
|
|
"job_post_id": job.id,
|
|
"requested_by": TalentRuns._as_uuid((current_user or {}).get("id")),
|
|
"status": "pending",
|
|
"actor_id": plugins.APIFY_ACTOR_ID,
|
|
"search_input": actor_input,
|
|
"max_results": max_results,
|
|
})
|
|
|
|
try:
|
|
started = await plugins.start_actor_run(actor_input)
|
|
except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc:
|
|
# Keep the failed row for run history, then surface the vendor error.
|
|
await TalentRuns.mark_failed(self.session, run.id, str(exc))
|
|
raise HTTPException(status_code=502, detail=f"Apify run could not be started: {exc}")
|
|
|
|
run = await TalentRuns.mark_started(
|
|
self.session,
|
|
run.id,
|
|
apify_run_id=started.get("id"),
|
|
apify_dataset_id=started.get("defaultDatasetId"),
|
|
)
|
|
return serialize_talent_run(run)
|
|
|
|
async def run_status(self, run_id):
|
|
run = await TalentRuns.get_by_id(self.session, run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Talent run not found")
|
|
|
|
# Terminal runs are immutable: no Apify call, no re-persist. This makes
|
|
# the poll endpoint idempotent and cheap once a run has settled.
|
|
if run.status in plugins.TERMINAL_STATUSES:
|
|
return serialize_talent_run(run)
|
|
|
|
if not run.apify_run_id:
|
|
# pending row whose start call never completed (crash between insert
|
|
# and mark_started): nothing to poll, mark it failed.
|
|
run = await TalentRuns.mark_failed(
|
|
self.session, run.id, "Run was never started on Apify"
|
|
)
|
|
return serialize_talent_run(run)
|
|
|
|
try:
|
|
remote = await plugins.get_run(run.apify_run_id)
|
|
except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc:
|
|
if isinstance(exc, plugins.ApifyError) and exc.code == "record-not-found":
|
|
run = await TalentRuns.mark_failed(
|
|
self.session, run.id, "Apify run no longer exists"
|
|
)
|
|
return serialize_talent_run(run)
|
|
raise HTTPException(status_code=502, detail=f"Apify status check failed: {exc}")
|
|
|
|
status = plugins.local_status(remote.get("status"))
|
|
if status == "running":
|
|
run = await TalentRuns.mark_status(self.session, run.id, "running")
|
|
return serialize_talent_run(run)
|
|
|
|
if status == "succeeded":
|
|
dataset_id = run.apify_dataset_id or remote.get("defaultDatasetId")
|
|
try:
|
|
items = await plugins.get_dataset_items(dataset_id, limit=run.max_results)
|
|
except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc:
|
|
raise HTTPException(status_code=502, detail=f"Apify dataset fetch failed: {exc}")
|
|
normalized = [
|
|
p
|
|
for p in (plugins.normalize_profile(i) for i in items)
|
|
if p and not plugins.is_excluded_profile(p)
|
|
]
|
|
job = await JobPosts.get_job_post_by_id(self.session, run.job_post_id)
|
|
if job:
|
|
job_fields = {
|
|
"title": job.title,
|
|
"requirements": job.requirements,
|
|
"optional_skills": job.optional_skills,
|
|
}
|
|
for profile in normalized:
|
|
profile["match_score"] = plugins.relevance_score(job_fields, profile)
|
|
count = await TalentProfiles.upsert_from_items(
|
|
self.session,
|
|
job_post_id=run.job_post_id,
|
|
run_id=run.id,
|
|
normalized_items=normalized,
|
|
)
|
|
found_so_far = (run.profiles_found or 0) + count
|
|
|
|
# Thin results: broaden and keep the same run going instead of
|
|
# settling for one lonely card. Each rung is a fresh actor run on
|
|
# the same row; the frontend just sees "running" a while longer.
|
|
if len(items) < min(10, run.max_results):
|
|
broadened = plugins.broaden_actor_input(run.search_input or {})
|
|
if broadened:
|
|
try:
|
|
started = await plugins.start_actor_run(broadened)
|
|
except (httpx.HTTPError, plugins.ApifyError, RuntimeError):
|
|
# Keep what we already found rather than failing the run.
|
|
started = None
|
|
if started:
|
|
run = await TalentRuns.mark_rearmed(
|
|
self.session,
|
|
run.id,
|
|
apify_run_id=started.get("id"),
|
|
apify_dataset_id=started.get("defaultDatasetId"),
|
|
search_input=broadened,
|
|
found_so_far=found_so_far,
|
|
)
|
|
return serialize_talent_run(run)
|
|
|
|
run = await TalentRuns.mark_succeeded(
|
|
self.session, run.id, profiles_found=found_so_far
|
|
)
|
|
return serialize_talent_run(run)
|
|
|
|
# failed / timed_out / aborted
|
|
message = remote.get("statusMessage") or f"Apify run {remote.get('status')}"
|
|
run = await TalentRuns.mark_failed(self.session, run.id, message, status=status)
|
|
return serialize_talent_run(run)
|
|
|
|
async def fetch_runs(self, job_post_id):
|
|
await self._get_job(job_post_id)
|
|
rows, total = await TalentRuns.fetch_runs(self.session, job_post_id=job_post_id)
|
|
return [serialize_talent_run(r) for r in rows], total
|
|
|
|
async def fetch_profiles(self, job_post_id, search=None, top=None, skip=0):
|
|
await self._get_job(job_post_id)
|
|
rows, total = await TalentProfiles.fetch_profiles(
|
|
self.session, job_post_id=job_post_id, search=search, top=top, skip=skip
|
|
)
|
|
profiles = [serialize_talent_profile(r) for r in rows]
|
|
profiles = await annotate_applications(self.session, profiles)
|
|
return profiles, total
|
|
|
|
async def get_profile(self, profile_id):
|
|
row = await TalentProfiles.get_profile_by_id(self.session, profile_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Talent profile not found")
|
|
data = serialize_talent_profile_detail(row)
|
|
await annotate_applications(self.session, [data])
|
|
return data
|
|
|
|
async def delete_profile(self, profile_id):
|
|
row = await TalentProfiles.soft_delete_profile(self.session, profile_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Talent profile not found")
|
|
return {"id": str(row.id), "deleted": True}
|