diff --git a/apps/core-api/.env.example b/apps/core-api/.env.example index 09e22a1..d76959f 100644 --- a/apps/core-api/.env.example +++ b/apps/core-api/.env.example @@ -10,3 +10,13 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60 REFRESH_TOKEN_EXPIRE_DAYS=7 REFRESH_COOKIE_SECURE=false CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# MinIO / S3 (local dev — matches docker-compose) +MINIO_ENDPOINT=127.0.0.1:9000 +MINIO_ACCESS_KEY=minio_admin +MINIO_SECRET_KEY=minio_password +MINIO_SECURE=false +MINIO_BUCKET_RESUMES=resumes + +# Internal parsing service (resume extraction) +PARSING_SERVICE_URL=http://127.0.0.1:8002/internal/v1 diff --git a/apps/core-api/src/api/routes/__init__.py b/apps/core-api/src/api/routes/__init__.py index e9fc45a..b98ae75 100644 --- a/apps/core-api/src/api/routes/__init__.py +++ b/apps/core-api/src/api/routes/__init__.py @@ -1,9 +1,12 @@ from fastapi import APIRouter -from src.api.routes import auth, jobs, organizations, system +from src.api.routes import applications, auth, jobs, organizations, system api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth.router) api_router.include_router(organizations.router) api_router.include_router(jobs.router) +api_router.include_router(applications.router) +api_router.include_router(applications.applicant_router) +api_router.include_router(applications.detail_router) api_router.include_router(system.router) diff --git a/apps/core-api/src/api/routes/applications.py b/apps/core-api/src/api/routes/applications.py new file mode 100644 index 0000000..4a2ef60 --- /dev/null +++ b/apps/core-api/src/api/routes/applications.py @@ -0,0 +1,183 @@ +"""Bulk resume upload routes under a job.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from src.api.deps import DbSession, require_roles +from src.models.enums import UserRole +from src.models.user import User +from src.schemas.application import ( + ApplicationDetailResponse, + ApplicantListItem, + BulkCreateRequest, + BulkCreateResponse, + JobFitRunResponse, + UploadUrlsRequest, + UploadUrlsResponse, +) +from src.services import application_service, job_service + +router = APIRouter( + prefix="/jobs/{job_id}/applications", + tags=["Candidate Applications"], +) +applicant_router = APIRouter( + prefix="/jobs/{job_id}/applicants", + tags=["Candidate Applications"], +) +detail_router = APIRouter( + prefix="/applications", + tags=["Candidate Applications"], +) + +JobActor = Annotated[ + User, + Depends( + require_roles( + UserRole.hr, UserRole.organization_admin, UserRole.super_admin + ) + ), +] + + +def _assert_job_access(user: User, job) -> None: + if user.role == UserRole.super_admin: + return + if user.organization_id != job.organization_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot access another organization", + ) + + +@router.post( + "/upload-urls", + response_model=UploadUrlsResponse, + summary="Issue pre-signed S3 PUT URLs for HR bulk resume upload", +) +def create_upload_urls( + job_id: UUID, + body: UploadUrlsRequest, + db: DbSession, + current_user: JobActor, +) -> UploadUrlsResponse: + job = job_service.get_job(db, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + _assert_job_access(current_user, job) + try: + application_service.assert_job_accepts_applications(job) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + try: + return application_service.create_upload_urls(job, body.files) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to create upload URLs: {exc}", + ) from exc + + +@router.post( + "/bulk", + response_model=BulkCreateResponse, + status_code=status.HTTP_202_ACCEPTED, + summary="Register S3-uploaded resumes and process each independently", +) +def bulk_create_applications( + job_id: UUID, + body: BulkCreateRequest, + db: DbSession, + current_user: JobActor, +) -> BulkCreateResponse: + job = job_service.get_job(db, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + _assert_job_access(current_user, job) + try: + return application_service.enqueue_bulk_resumes(job, body) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + + +@applicant_router.get( + "", + response_model=list[ApplicantListItem], + summary="List applicants for a job sorted by score", +) +def list_applicants( + job_id: UUID, + db: DbSession, + current_user: JobActor, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=50), +) -> list[ApplicantListItem]: + job = job_service.get_job(db, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + _assert_job_access(current_user, job) + return application_service.list_applicants(db, job_id=job_id, page=page, limit=limit) + + +@router.post( + "/{application_id}/rerun-fit", + response_model=JobFitRunResponse, + summary="Recalculate job-fit for an application", +) +def rerun_job_fit( + job_id: UUID, + application_id: UUID, + db: DbSession, + current_user: JobActor, +) -> JobFitRunResponse: + job = job_service.get_job(db, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + _assert_job_access(current_user, job) + + application = application_service.get_application(db, application_id) + if application is None or application.job_description_id != job_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found", + ) + try: + return application_service.rerun_job_fit(db, application=application) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@detail_router.get( + "/{application_id}", + response_model=ApplicationDetailResponse, + summary="View application details with parsed resume and match data", +) +def get_application( + application_id: UUID, + db: DbSession, + current_user: JobActor, +) -> ApplicationDetailResponse: + application = application_service.get_application(db, application_id) + if application is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found", + ) + + job = application.job_description + if job is None: + job = job_service.get_job(db, application.job_description_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + _assert_job_access(current_user, job) + return application_service.application_to_detail_response(application) diff --git a/apps/core-api/src/api/routes/jobs.py b/apps/core-api/src/api/routes/jobs.py index 7b9e0f0..ea4d760 100644 --- a/apps/core-api/src/api/routes/jobs.py +++ b/apps/core-api/src/api/routes/jobs.py @@ -102,9 +102,13 @@ def create_job( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) - ) from exc + detail = str(exc) + code = ( + status.HTTP_502_BAD_GATEWAY + if "unavailable" in detail.lower() + else status.HTTP_400_BAD_REQUEST + ) + raise HTTPException(status_code=code, detail=detail) from exc return JobResponse.model_validate(job) @@ -140,5 +144,14 @@ def update_job( if job is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") _assert_job_access(current_user, job) - job = job_service.update_job(db, job, body) + try: + job = job_service.update_job(db, job, body) + except ValueError as exc: + detail = str(exc) + code = ( + status.HTTP_502_BAD_GATEWAY + if "unavailable" in detail.lower() + else status.HTTP_400_BAD_REQUEST + ) + raise HTTPException(status_code=code, detail=detail) from exc return JobUpdateResponse.model_validate(job) diff --git a/apps/core-api/src/config/settings.py b/apps/core-api/src/config/settings.py index f9b91fc..3c8754e 100644 --- a/apps/core-api/src/config/settings.py +++ b/apps/core-api/src/config/settings.py @@ -23,10 +23,26 @@ class Settings(BaseSettings): refresh_cookie_samesite: str = "lax" cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + # MinIO / S3 (local dev defaults match docker-compose) + minio_endpoint: str = "127.0.0.1:9000" + minio_access_key: str = "minio_admin" + minio_secret_key: str = "minio_password" + minio_secure: bool = False + minio_bucket_resumes: str = "resumes" + s3_presign_expires_seconds: int = 900 + + # Internal parsing service (resume extraction) + parsing_service_url: str = "http://127.0.0.1:8002/internal/v1" + @property def cors_origin_list(self) -> list[str]: origins = [o.strip() for o in self.cors_origins.split(",") if o.strip()] return origins or ["*"] + @property + def s3_endpoint_url(self) -> str: + scheme = "https" if self.minio_secure else "http" + return f"{scheme}://{self.minio_endpoint}" + settings = Settings() diff --git a/apps/core-api/src/schemas/application.py b/apps/core-api/src/schemas/application.py new file mode 100644 index 0000000..56244e8 --- /dev/null +++ b/apps/core-api/src/schemas/application.py @@ -0,0 +1,109 @@ +"""Application bulk upload request/response schemas.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + +ALLOWED_RESUME_CONTENT_TYPES = frozenset( + { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + } +) + + +class UploadFileRequest(BaseModel): + file_name: str = Field(min_length=1, max_length=512) + content_type: str = Field(min_length=1, max_length=255) + + @field_validator("file_name", mode="before") + @classmethod + def strip_file_name(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("content_type") + @classmethod + def validate_content_type(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in ALLOWED_RESUME_CONTENT_TYPES: + raise ValueError( + "content_type must be application/pdf or " + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + return normalized + + +class UploadUrlsRequest(BaseModel): + files: list[UploadFileRequest] = Field(min_length=1, max_length=50) + + +class UploadUrlItem(BaseModel): + file_name: str + s3_key: str + upload_url: str + expires_at: datetime + + +class UploadUrlsResponse(BaseModel): + uploads: list[UploadUrlItem] + + +class BulkResumeItem(BaseModel): + s3_key: str = Field(min_length=1, max_length=1024) + file_name: str = Field(min_length=1, max_length=512) + + @field_validator("file_name", "s3_key", mode="before") + @classmethod + def strip_strings(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value + + +class BulkCreateRequest(BaseModel): + resumes: list[BulkResumeItem] = Field(min_length=1, max_length=50) + + +class BulkCreateResponse(BaseModel): + job_id: UUID + queued: int + + +class ApplicantListItem(BaseModel): + id: UUID + job_description_id: UUID + candidate_id: UUID + status: str + candidate_yoe: float | None = None + resume_score: Decimal | None = None + first_name: str | None = None + last_name: str | None = None + email: str | None = None + phone: str | None = None + + +class JobFitRunResponse(BaseModel): + application_id: UUID + status: str + resume_score: Decimal | None = None + + +class ApplicationDetailResponse(BaseModel): + id: UUID + job_description_id: UUID + candidate_id: UUID + status: str + candidate_yoe: float | None = None + resume_score: Decimal | None = None + first_name: str | None = None + last_name: str | None = None + email: str | None = None + phone: str | None = None + parsed_resume: dict | None = None + job_fit_analysis: dict | None = None diff --git a/apps/core-api/src/services/__init__.py b/apps/core-api/src/services/__init__.py index 28e87db..3f7b5f5 100644 --- a/apps/core-api/src/services/__init__.py +++ b/apps/core-api/src/services/__init__.py @@ -9,7 +9,7 @@ refresh_access_token, revoke_refresh_token, ) -from src.services import job_service, organization_service, platform_service, user_service +from src.services import application_service, job_service, organization_service, platform_service, user_service __all__ = [ "OrgAuthError", @@ -21,6 +21,7 @@ "issue_token_pair", "refresh_access_token", "revoke_refresh_token", + "application_service", "job_service", "organization_service", "platform_service", diff --git a/apps/core-api/src/services/application_ai_service.py b/apps/core-api/src/services/application_ai_service.py new file mode 100644 index 0000000..bd2a3cc --- /dev/null +++ b/apps/core-api/src/services/application_ai_service.py @@ -0,0 +1,62 @@ +"""Internal AI service callers for resume parse and job-fit.""" + +from __future__ import annotations + +from uuid import UUID + +import httpx + +from src.config.settings import settings + +__all__ = [ + "call_parse_resume", + "call_match_resume_jd", +] + + +def _ai_url(path: str) -> str: + return f"{settings.parsing_service_url.rstrip('/')}/{path.lstrip('/')}" + + +def call_parse_resume(*, s3_key: str, file_name: str) -> dict: + payload = { + "resume_name": file_name, + "resume_path": s3_key, + } + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(_ai_url("parse/resume"), json=payload) + response.raise_for_status() + except httpx.HTTPError as exc: + raise ValueError(f"Resume parsing service unavailable: {exc}") from exc + + data = response.json() + if not isinstance(data, dict): + raise ValueError("Invalid response from resume parsing service") + return data + + +def call_match_resume_jd( + *, + application_id: UUID, + job_id: UUID, + parsed_jd: dict, + parsed_resume: dict, +) -> dict: + payload = { + "application_id": str(application_id), + "job_id": str(job_id), + "parsed_resume": parsed_resume, + "parsed_jd": parsed_jd, + } + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(_ai_url("match/resume-jd"), json=payload) + response.raise_for_status() + except httpx.HTTPError as exc: + raise ValueError(f"Job-fit service unavailable: {exc}") from exc + + data = response.json() + if not isinstance(data, dict): + raise ValueError("Invalid response from job-fit service") + return data diff --git a/apps/core-api/src/services/application_ingest_service.py b/apps/core-api/src/services/application_ingest_service.py new file mode 100644 index 0000000..b995c29 --- /dev/null +++ b/apps/core-api/src/services/application_ingest_service.py @@ -0,0 +1,260 @@ +"""HR bulk resume ingest flow.""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from src.db.session import SessionLocal +from src.models.application import Application +from src.models.enums import ApplicationStatus, JobStatus, UserRole, UserStatus +from src.models.job_description import JobDescription +from src.models.user import User +from src.schemas.application import ( + BulkCreateRequest, + BulkCreateResponse, + UploadFileRequest, + UploadUrlItem, + UploadUrlsResponse, +) +from src.services import storage_service +from src.services.application_ai_service import call_parse_resume +from src.services.application_job_fit_service import apply_job_fit + +logger = logging.getLogger(__name__) + +__all__ = [ + "assert_job_accepts_applications", + "create_upload_urls", + "enqueue_bulk_resumes", +] + + +def assert_job_accepts_applications(job: JobDescription) -> None: + if job.status != JobStatus.published: + raise ValueError("Job must be published to accept applications") + + +def create_upload_urls( + job: JobDescription, + files: list[UploadFileRequest], +) -> UploadUrlsResponse: + uploads: list[UploadUrlItem] = [] + for file in files: + s3_key = storage_service.build_resume_s3_key( + job.organization_id, + job.id, + file.file_name, + ) + upload_url, expires_at = storage_service.create_presigned_upload_url( + s3_key, + file.content_type, + ) + uploads.append( + UploadUrlItem( + file_name=file.file_name, + s3_key=s3_key, + upload_url=upload_url, + expires_at=expires_at, + ) + ) + return UploadUrlsResponse(uploads=uploads) + + +def enqueue_bulk_resumes( + job: JobDescription, + body: BulkCreateRequest, +) -> BulkCreateResponse: + assert_job_accepts_applications(job) + + for resume in body.resumes: + storage_service.validate_resume_s3_key( + resume.s3_key, + organization_id=job.organization_id, + job_id=job.id, + ) + + for resume in body.resumes: + thread = threading.Thread( + target=_process_resume, + args=(job.id, resume.s3_key, resume.file_name), + daemon=True, + name=f"resume-{resume.file_name[:40]}", + ) + thread.start() + + return BulkCreateResponse(job_id=job.id, queued=len(body.resumes)) + + +def _process_resume(job_id: UUID, s3_key: str, file_name: str) -> None: + db = SessionLocal() + try: + job = db.get(JobDescription, job_id) + if job is None: + logger.error("Job %s not found while processing %s", job_id, file_name) + return + + parse_payload = call_parse_resume(s3_key=s3_key, file_name=file_name) + if parse_payload.get("status") != "success": + raise ValueError( + parse_payload.get("error_message") or "Resume parsing did not succeed" + ) + + parsed_resume = parse_payload.get("parsed_resume") + if not isinstance(parsed_resume, dict): + raise ValueError("Parse response missing parsed_resume") + logger.info( + "Resume parse success for job %s, file %s", + job_id, + file_name, + ) + + personal = _personal_info(parsed_resume) + email = _extract_email(personal) + if not email: + raise ValueError("Could not extract candidate email from parsed resume") + + candidate = _find_or_create_candidate(db, email, personal) + application = _create_application( + db, + job=job, + candidate=candidate, + s3_key=s3_key, + parsed_resume=parsed_resume, + ) + logger.info( + "Application created for job %s, file %s, candidate %s, application %s", + job_id, + file_name, + candidate.id, + application.id, + ) + db.commit() + db.refresh(application) + db.refresh(job) + + apply_job_fit(db, job, application, parsed_resume) + db.commit() + logger.info( + "Resume pipeline completed for job %s, file %s, application %s", + job_id, + file_name, + application.id, + ) + except Exception: + logger.exception("Failed processing resume %s for job %s", file_name, job_id) + db.rollback() + finally: + db.close() + + +def _find_or_create_candidate( + db: Session, + email: str, + personal: dict, +) -> User: + existing = db.scalar(select(User).where(User.email == email)) + if existing is not None: + if existing.role != UserRole.candidate: + raise ValueError(f"Email {email} belongs to a non-candidate user") + _fill_candidate_profile(existing, personal) + db.add(existing) + db.flush() + return existing + + candidate = User( + email=email, + role=UserRole.candidate, + organization_id=None, + password_hash=None, + first_name=_string_field(personal, "first_name", max_len=100), + last_name=_string_field(personal, "last_name", max_len=100), + phone=_string_field(personal, "phone_number", max_len=50) + or _string_field(personal, "phone", max_len=50), + status=UserStatus.active, + ) + db.add(candidate) + db.flush() + return candidate + + +def _fill_candidate_profile(user: User, personal: dict) -> None: + first_name = _string_field(personal, "first_name", max_len=100) + last_name = _string_field(personal, "last_name", max_len=100) + phone = _string_field(personal, "phone_number", max_len=50) or _string_field( + personal, "phone", max_len=50 + ) + if first_name and not user.first_name: + user.first_name = first_name + if last_name and not user.last_name: + user.last_name = last_name + if phone and not user.phone: + user.phone = phone + + +def _create_application( + db: Session, + *, + job: JobDescription, + candidate: User, + s3_key: str, + parsed_resume: dict, +) -> Application: + now = datetime.now(timezone.utc) + application = Application( + job_description_id=job.id, + candidate_id=candidate.id, + resume_url=s3_key, + parsed_resume=parsed_resume, + candidate_yoe=_extract_yoe(parsed_resume), + status=ApplicationStatus.applied, + applied_at=now, + ) + db.add(application) + try: + db.flush() + except IntegrityError as exc: + raise ValueError("Candidate has already applied to this job") from exc + return application + + +def _personal_info(parsed_resume: dict) -> dict: + info = parsed_resume.get("personal_info") + return info if isinstance(info, dict) else {} + + +def _string_field(data: dict, key: str, *, max_len: int) -> str | None: + value = data.get(key) + if value is None: + return None + text = str(value).strip() + if not text: + return None + return text[:max_len] + + +def _extract_email(personal: dict) -> str: + value = personal.get("email") + if isinstance(value, str) and "@" in value.strip(): + return value.strip().lower() + return "" + + +def _extract_yoe(parsed_resume: dict) -> float | None: + experience = parsed_resume.get("experience") + if isinstance(experience, dict): + total = experience.get("total_years") + if isinstance(total, (int, float)): + return float(total) + relevant = parsed_resume.get("relevant_experience") + if isinstance(relevant, dict): + total = relevant.get("total_years") + if isinstance(total, (int, float)): + return float(total) + return None diff --git a/apps/core-api/src/services/application_job_fit_service.py b/apps/core-api/src/services/application_job_fit_service.py new file mode 100644 index 0000000..24db7b6 --- /dev/null +++ b/apps/core-api/src/services/application_job_fit_service.py @@ -0,0 +1,103 @@ +"""Job-fit calculation orchestration for applications.""" + +from __future__ import annotations + +import logging +from decimal import Decimal + +from sqlalchemy.orm import Session + +from src.models.application import Application +from src.models.job_description import JobDescription +from src.schemas.application import JobFitRunResponse +from src.services.application_ai_service import call_match_resume_jd + +logger = logging.getLogger(__name__) + +__all__ = [ + "apply_job_fit", + "rerun_job_fit", +] + + +def apply_job_fit( + db: Session, + job: JobDescription, + application: Application, + parsed_resume: dict, +) -> None: + parsed_jd = job.parsed_jd + if not parsed_jd: + logger.warning( + "Job %s has no parsed_jd; skipping job-fit for application %s", + job.id, + application.id, + ) + return + + try: + fit = call_match_resume_jd( + application_id=application.id, + job_id=job.id, + parsed_jd=parsed_jd, + parsed_resume=parsed_resume, + ) + except Exception: + logger.exception( + "Job-fit failed for application %s; application kept without score", + application.id, + ) + return + + if fit.get("status") != "success": + logger.warning( + "Job-fit returned non-success for application %s: %s", + application.id, + fit.get("error_message") or fit, + ) + return + + score = fit.get("resume_score") + if not isinstance(score, (int, float)): + score = fit.get("match_score") + if isinstance(score, (int, float)): + application.resume_score = Decimal(str(score)) + + yoe = fit.get("candidate_yoe") + if isinstance(yoe, (int, float)): + application.candidate_yoe = float(yoe) + + analysis = fit.get("job_fit_analysis") + if isinstance(analysis, dict): + application.job_fit_analysis = analysis + + db.add(application) + logger.info( + "Job-fit success for application %s: score=%s, yoe=%s", + application.id, + application.resume_score, + application.candidate_yoe, + ) + + +def rerun_job_fit( + db: Session, + *, + application: Application, +) -> JobFitRunResponse: + job = application.job_description + if job is None: + job = db.get(JobDescription, application.job_description_id) + if job is None: + raise LookupError("Job not found") + if not isinstance(application.parsed_resume, dict): + raise ValueError("Application has no parsed_resume to match") + + apply_job_fit(db, job, application, application.parsed_resume) + db.commit() + db.refresh(application) + return JobFitRunResponse( + application_id=application.id, + status="completed", + resume_score=application.resume_score, + ) diff --git a/apps/core-api/src/services/application_queries_service.py b/apps/core-api/src/services/application_queries_service.py new file mode 100644 index 0000000..dac385d --- /dev/null +++ b/apps/core-api/src/services/application_queries_service.py @@ -0,0 +1,83 @@ +"""Application read/query helpers.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from src.models.application import Application +from src.schemas.application import ApplicationDetailResponse, ApplicantListItem + +__all__ = [ + "list_applicants", + "get_application", + "application_to_detail_response", +] + + +def list_applicants( + db: Session, + *, + job_id: UUID, + page: int = 1, + limit: int = 50, +) -> list[ApplicantListItem]: + offset = max(page - 1, 0) * limit + stmt = ( + select(Application) + .options(selectinload(Application.candidate)) + .where(Application.job_description_id == job_id) + .order_by(Application.resume_score.desc().nullslast(), Application.created_at.desc()) + .offset(offset) + .limit(min(limit, 50)) + ) + applications = list(db.scalars(stmt).all()) + return [ + ApplicantListItem( + id=application.id, + job_description_id=application.job_description_id, + candidate_id=application.candidate_id, + status=application.status.value, + candidate_yoe=application.candidate_yoe, + resume_score=application.resume_score, + first_name=application.candidate.first_name if application.candidate else None, + last_name=application.candidate.last_name if application.candidate else None, + email=application.candidate.email if application.candidate else None, + phone=application.candidate.phone if application.candidate else None, + ) + for application in applications + ] + + +def get_application(db: Session, application_id: UUID) -> Application | None: + stmt = ( + select(Application) + .options( + selectinload(Application.candidate), + selectinload(Application.job_description), + ) + .where(Application.id == application_id) + ) + return db.scalar(stmt) + + +def application_to_detail_response( + application: Application, +) -> ApplicationDetailResponse: + candidate = application.candidate + return ApplicationDetailResponse( + id=application.id, + job_description_id=application.job_description_id, + candidate_id=application.candidate_id, + status=application.status.value, + candidate_yoe=application.candidate_yoe, + resume_score=application.resume_score, + first_name=candidate.first_name if candidate else None, + last_name=candidate.last_name if candidate else None, + email=candidate.email if candidate else None, + phone=candidate.phone if candidate else None, + parsed_resume=application.parsed_resume, + job_fit_analysis=application.job_fit_analysis, + ) diff --git a/apps/core-api/src/services/application_service.py b/apps/core-api/src/services/application_service.py new file mode 100644 index 0000000..867586d --- /dev/null +++ b/apps/core-api/src/services/application_service.py @@ -0,0 +1,22 @@ +"""Public facade for application-related services.""" + +__all__ = [ + "create_upload_urls", + "enqueue_bulk_resumes", + "assert_job_accepts_applications", + "list_applicants", + "get_application", + "application_to_detail_response", + "rerun_job_fit", +] +from src.services.application_ingest_service import ( + assert_job_accepts_applications, + create_upload_urls, + enqueue_bulk_resumes, +) +from src.services.application_job_fit_service import rerun_job_fit +from src.services.application_queries_service import ( + application_to_detail_response, + get_application, + list_applicants, +) diff --git a/apps/core-api/src/services/job_service.py b/apps/core-api/src/services/job_service.py index 9dcc065..0531b30 100644 --- a/apps/core-api/src/services/job_service.py +++ b/apps/core-api/src/services/job_service.py @@ -1,19 +1,32 @@ -"""Job description CRUD (form-created jobs; no JD file/text parse).""" +"""Job description CRUD. Form fields are sent to AI parse/jd; parsed_jd is stored on the job.""" from __future__ import annotations from datetime import datetime, timezone from uuid import UUID +import httpx from sqlalchemy import select from sqlalchemy.orm import Session +from src.config.settings import settings from src.models.enums import JobStatus, UserRole from src.models.job_description import JobDescription from src.models.organization import Organization from src.models.user import User from src.schemas.job import JobCreate, JobUpdate +_JD_PARSE_FIELDS = ( + "title", + "description", + "job_type", + "work_type", + "location", + "experience_min", + "experience_max", + "skills", +) + __all__ = [ "list_jobs", "get_job", @@ -88,9 +101,11 @@ def create_job( ) -> JobDescription: _assert_org_active(db, organization_id) payload = data.model_dump(exclude={"organization_id"}) + parsed_jd = _call_parse_jd(data) job = JobDescription( organization_id=organization_id, created_by=created_by, + parsed_jd=parsed_jd, **payload, ) _apply_status_timestamps(job, job.status) @@ -107,7 +122,47 @@ def update_job(db: Session, job: JobDescription, data: JobUpdate) -> JobDescript setattr(job, key, value) if new_status is not None: _apply_status_timestamps(job, new_status) + if any(field in payload for field in _JD_PARSE_FIELDS): + job.parsed_jd = _call_parse_jd(job) db.add(job) db.commit() db.refresh(job) return job + + +def _enum_value(value: object) -> object: + return value.value if hasattr(value, "value") else value + + +def _jd_parse_body(source: JobCreate | JobDescription) -> dict: + return { + "title": source.title, + "description": source.description, + "job_type": _enum_value(source.job_type), + "work_type": _enum_value(source.work_type), + "location": source.location, + "experience_min": source.experience_min, + "experience_max": source.experience_max, + "skills": source.skills, + "status": _enum_value(source.status), + } + + +def _call_parse_jd(source: JobCreate | JobDescription) -> dict: + url = f"{settings.parsing_service_url.rstrip('/')}/parse/jd" + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(url, json=_jd_parse_body(source)) + response.raise_for_status() + except httpx.HTTPError as exc: + raise ValueError(f"JD parsing service unavailable: {exc}") from exc + + data = response.json() + if not isinstance(data, dict): + raise ValueError("Invalid response from JD parsing service") + if data.get("status") != "success": + raise ValueError(data.get("error_message") or "JD parsing did not succeed") + parsed_jd = data.get("parsed_jd") + if not isinstance(parsed_jd, dict): + raise ValueError("JD parse response missing parsed_jd") + return parsed_jd diff --git a/apps/core-api/src/services/storage_service.py b/apps/core-api/src/services/storage_service.py new file mode 100644 index 0000000..dd89abe --- /dev/null +++ b/apps/core-api/src/services/storage_service.py @@ -0,0 +1,86 @@ +"""S3/MinIO helpers for resume uploads.""" + +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timedelta, timezone + +import boto3 +from botocore.client import Config + +from src.config.settings import settings + +__all__ = [ + "build_resume_s3_key", + "resume_key_prefix", + "create_presigned_upload_url", + "validate_resume_s3_key", +] + +_UNSAFE_FILENAME = re.compile(r"[^a-zA-Z0-9._-]+") + + +def _s3_client(): + return boto3.client( + "s3", + endpoint_url=settings.s3_endpoint_url, + aws_access_key_id=settings.minio_access_key, + aws_secret_access_key=settings.minio_secret_key, + config=Config(signature_version="s3v4"), + region_name="us-east-1", + ) + + +def sanitize_file_name(file_name: str) -> str: + base = file_name.strip().replace("\\", "/").rsplit("/", 1)[-1] + safe = _UNSAFE_FILENAME.sub("-", base).strip("-") + return safe or "resume" + + +def resume_key_prefix(organization_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"orgs/{organization_id}/jobs/{job_id}/resumes/" + + +def build_resume_s3_key( + organization_id: uuid.UUID, + job_id: uuid.UUID, + file_name: str, +) -> str: + """orgs/{org}/jobs/{job}/resumes/{upload_id}-{file_name}""" + upload_id = uuid.uuid4() + stored_name = f"{upload_id}-{sanitize_file_name(file_name)}" + return f"{resume_key_prefix(organization_id, job_id)}{stored_name}" + + +def validate_resume_s3_key( + s3_key: str, + *, + organization_id: uuid.UUID, + job_id: uuid.UUID, +) -> None: + prefix = resume_key_prefix(organization_id, job_id) + if not s3_key.startswith(prefix): + raise ValueError(f"s3_key must start with {prefix}") + remainder = s3_key[len(prefix) :] + if not remainder or "/" in remainder: + raise ValueError("Invalid s3_key path") + + +def create_presigned_upload_url( + s3_key: str, + content_type: str, +) -> tuple[str, datetime]: + client = _s3_client() + expires_in = settings.s3_presign_expires_seconds + upload_url = client.generate_presigned_url( + "put_object", + Params={ + "Bucket": settings.minio_bucket_resumes, + "Key": s3_key, + "ContentType": content_type, + }, + ExpiresIn=expires_in, + ) + expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return upload_url, expires_at diff --git a/docker-compose.yml b/docker-compose.yml index 7b66cea..34f991c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgres:15-alpine @@ -10,7 +8,7 @@ services: POSTGRES_PASSWORD: ezscreen_password POSTGRES_DB: ezscreen_db ports: - - "5432:5432" + - "5433:5432" # host 5433 avoids conflict with local PostgreSQL on 5432 volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -106,7 +104,7 @@ services: - VITE_AI_SCREENING_URL=ws://localhost:8001 depends_on: - core-api - - ai-screening + - ai-core-services volumes: pgdata: diff --git a/docs/architecture/AI_PROCESSING.md b/docs/architecture/AI_PROCESSING.md index a407473..acecc47 100644 --- a/docs/architecture/AI_PROCESSING.md +++ b/docs/architecture/AI_PROCESSING.md @@ -21,7 +21,11 @@ ## 1. Pipeline Overview -There are two AI pipelines for Phase 1. Both follow the same pattern: **API enqueues task → broker delivers → worker processes → worker writes results to DB**. +There are two AI pipelines for Phase 1. + +**Pipeline A (JD)** runs once per job (create/publish): form text → `parsed_jd` on `job_descriptions`. + +**Pipeline B (resume)** runs once per file, independently (HR bulk or public apply). Core-api returns 202 on bulk; each resume is parse → candidate + application → job-fit (`parsed_jd` vs `parsed_resume`). Matching does not wait for other files. ```mermaid --- @@ -31,22 +35,19 @@ config: flowchart LR subgraph PA["Pipeline A: Job Description Parsing"] direction LR - PA1["HR uploads JD"] - PA2["Backend API
saves file to S3
creates JD record (draft)"] - PA3["Enqueue
parse-jd task"] - PA4["Worker picks up task"] - PA5["① LLM extraction: skills, qualifications,
responsibilities, location, type
② Fuzzy-map keys to schema
③ Write results to DB"] + PA1["HR saves job form"] + PA2["Core-api stores job"] + PA3["AI parse/jd"] + PA4["Write parsed_jd on job"] end PA1 --> PA2 PA2 --> PA3 PA3 --> PA4 - PA4 --> PA5 PA1:::actor PA2:::backend - PA3:::queue - PA4:::queue - PA5:::process + PA3:::ai + PA4:::process classDef actor fill:#3b82f6,stroke:#2563eb,stroke-width:2px,color:white classDef backend fill:#10b981,stroke:#059669,stroke-width:2px,color:white classDef queue fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:white @@ -61,24 +62,27 @@ config: layout: elk --- flowchart LR - subgraph PB["Pipeline B: Resume Parsing + Matching"] + subgraph PB["Pipeline B: Per-resume parse then job-fit"] direction LR - PB1["Candidate submits resume"] - PB2["Backend API
saves file to S3
creates application (applied)"] - PB3["Enqueue
parse-resume task"] - PB4["Worker picks up task"] - PB5["① Download resume from S3
② LLM extraction: primary_skills, secondary_skills,
domain_expertise, experience, education, certs
③ Calculate total_years
④ Fetch JD extracted_data from DB
⑤ LLM matching: score, matched/missing skills
⑥ Write results to DB"] + PB1["HR bulk or candidate apply"] + PB2["File on S3"] + PB3["AI parse/resume"] + PB4["Create candidate + application"] + PB5["AI match parsed_jd vs parsed_resume"] + PB6["Write score on application"] end PB1 --> PB2 PB2 --> PB3 PB3 --> PB4 PB4 --> PB5 + PB5 --> PB6 PB1:::actor PB2:::backend - PB3:::queue - PB4:::queue - PB5:::process + PB3:::ai + PB4:::backend + PB5:::ai + PB6:::process classDef actor fill:#3b82f6,stroke:#2563eb,stroke-width:2px,color:white classDef backend fill:#10b981,stroke:#059669,stroke-width:2px,color:white diff --git a/docs/architecture/API_SPEC.md b/docs/architecture/API_SPEC.md index 317ea5c..e7c9571 100644 --- a/docs/architecture/API_SPEC.md +++ b/docs/architecture/API_SPEC.md @@ -33,7 +33,7 @@ └─────────────────┬──────────────────────────────────────┬───────────────┘ │ │ Internal REST │ │ Internal REST - POST /parse/jd │ │ POST /questions/generate + POST /parse/resume (S3 → parsed_resume) │ POST /questions/generate POST /match ▼ ▼ POST /bot/dispatch & /evaluate ┌────────────────────────────────────┐ ┌────────────────────────────────────┐ │ Parsing & Matching Service │ │ AI Screening Service │ @@ -464,7 +464,7 @@ Response 201: ### C. Job Description Endpoints (Form Create & Unified Update) -HR creates jobs by filling the form. There is no JD PDF upload and no `POST /api/v1/jobs/parse`. +HR creates jobs by filling the form. There is no JD PDF upload and no `POST /api/v1/jobs/parse`. On create (and on update of JD form fields), core-api calls `POST /internal/v1/parse/jd` and stores `parsed_jd` on `job_descriptions`. #### POST /api/v1/jobs **Tag**: `Job Descriptions` @@ -832,6 +832,96 @@ Response 200: } ``` +#### POST /api/v1/jobs/{id}/applications/upload-urls +**Tag**: `Candidate Applications` +**Summary**: Issue pre-signed S3 PUT URLs for HR bulk resume upload +**Operation ID**: `createApplicationUploadUrls` +**Roles**: `hr`, `organization_admin`, `super_admin` + +Job must be `published`. Resumes are uploaded directly to S3 (bucket `resumes`). **Core API does not parse files.** + +Object key: `orgs/{organization_id}/jobs/{job_id}/resumes/{uuid}-{file_name}`. + +Bulk flow: upload-urls → direct S3 PUT → `POST /api/v1/jobs/{id}/applications/bulk` → per-resume parse → candidate/application create → job-fit → `GET /api/v1/jobs/{id}/applicants`. + +```json +Request: +{ + "headers": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "path": { + "id": "444e4567-e89b-12d3-a456-426614174000" + }, + "body": { + "files": [ + { + "file_name": "john-doe.pdf", + "content_type": "application/pdf" + }, + { + "file_name": "jane-smith.docx", + "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + } + ] + } +} + +Response 200: +{ + "uploads": [ + { + "file_name": "john-doe.pdf", + "s3_key": "orgs/987e6543-e89b-12d3-a456-426614174000/jobs/444e4567-e89b-12d3-a456-426614174000/resumes/a1b2c3d4-john-doe.pdf", + "upload_url": "https://s3.amazonaws.com/...", + "expires_at": "2026-08-17T15:00:00Z" + } + ] +} +``` + +#### POST /api/v1/jobs/{id}/applications/bulk +**Tag**: `Candidate Applications` +**Summary**: Register S3-uploaded resumes; process each independently (parse → candidate → application → job-fit) +**Operation ID**: `bulkCreateApplications` +**Roles**: `hr`, `organization_admin`, `super_admin` + +Returns **202** immediately. There is **no batch_id** and **no bulk-status poll API**. Each resume is a separate async chain: + +1. `POST /internal/v1/parse/resume` — extract email, name, phone, skills, experience +2. Core-api creates/finds candidate user, then creates the application (`parsed_resume` stored) +3. `POST /internal/v1/match/resume-jd` — compare stored `parsed_jd` vs `parsed_resume`; write `resume_score` + `job_fit_analysis` + +HR sees progress via `GET /api/v1/jobs/{id}/applicants`. Core API never extracts resume text itself. + +```json +Request: +{ + "headers": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "path": { + "id": "444e4567-e89b-12d3-a456-426614174000" + }, + "body": { + "resumes": [ + { + "s3_key": "orgs/987e6543-e89b-12d3-a456-426614174000/jobs/444e4567-e89b-12d3-a456-426614174000/resumes/a1b2c3d4-john-doe.pdf", + "file_name": "john-doe.pdf" + } + ] + } +} + +Response 202: +{ + "job_id": "444e4567-e89b-12d3-a456-426614174000", + "queued": 1 +} +``` + --- ### F. Interview Sessions, Attendee Bot Dispatch & Analysis Endpoints @@ -1041,11 +1131,15 @@ Response 200: ### A. Parsing & Matching Microservice (`services/parsing-matching`) * **Base URL**: `http://parsing-matching:8001/internal/v1` +**Ownership**: All resume text extraction and structured field parsing (email, name, phone, skills, experience, education) lives in this service. Core API stores files on S3, calls parse with `resume_name` + `resume_path`, creates candidate + application from `parsed_resume`, then calls match with stored `parsed_jd` — it does **not** embed parsing or scoring logic. + #### POST /internal/v1/parse/jd **Tag**: `Internal Service` **Summary**: Internal JD parsing engine (services/parsing-matching) **Operation ID**: `internalParseJD` +**Called by**: Core API on `POST /api/v1/jobs` (and when JD form fields are updated). + ```json Request: { @@ -1053,21 +1147,42 @@ Request: "Content-Type": "application/json" }, "body": { - "raw_text": "Senior Java Developer..." + "title": "Senior Java Developer", + "description": "We are hiring a Senior Java Developer...", + "job_type": "full_time", + "work_type": "hybrid", + "location": "Bangalore", + "experience_min": 3, + "experience_max": 6, + "skills": "Java, Spring Boot, PostgreSQL, Docker, AWS, Kafka", + "status": "published" } } Response 200: { - "title": "Senior Java Developer", - "parsed_jd": {} + "status": "success", + "parsed_jd": { + "title": "Senior Java Developer", + "skills": { + "must_have": ["Java", "Spring Boot", "PostgreSQL"], + "good_to_have": ["Docker", "AWS", "Kafka"] + }, + "experience_required": { "min_years": 3.0, "max_years": 6.0 } + }, + "error_message": null } ``` +Core-api persists `parsed_jd` on the job. If `status` is not `success`, job create/update fails. + #### POST /internal/v1/parse/resume **Tag**: `Internal Service` -**Summary**: Internal resume parsing engine (services/parsing-matching) +**Summary**: Download resume from S3 and extract structured candidate details **Operation ID**: `internalParseResume` +**Called by**: Core API worker (after HR bulk upload or public apply) + +Flow: download file from `resume_path` (S3 object key) → text extraction → structured `parsed_resume`. ```json Request: @@ -1076,21 +1191,55 @@ Request: "Content-Type": "application/json" }, "body": { - "resume_base64": "", - "file_name": "resume.pdf" + "resume_name": "john-doe.pdf", + "resume_path": "orgs/987e6543-e89b-12d3-a456-426614174000/jobs/444e4567-e89b-12d3-a456-426614174000/resumes/a1b2c3d4-john-doe.pdf" } } Response 200: { - "parsed_resume": {} + "parsed_resume": { + "candidate_name": "John Doe", + "email": "john.doe@example.com", + "phone": "+1-555-0188", + "summary": "Senior backend engineer with 5 years in Java…", + "primary_skills": ["Java", "Spring Boot", "PostgreSQL"], + "secondary_skills": ["Docker", "AWS"], + "domain_expertise": ["FinTech"], + "relevant_experience": { + "total_years": 5.0, + "roles": [ + { + "title": "Senior Java Developer", + "company": "Acme Corp", + "start_date": "2021-01", + "end_date": null, + "years": 3.5, + "highlights": ["Built REST APIs", "Owned PostgreSQL schema"] + } + ] + }, + "education_certificates": [ + { + "name": "B.Tech Computer Science", + "issuer": "Example University", + "year": "2019", + "type": "degree" + } + ] + } } ``` +`resume_path` is the MinIO/S3 object key (same value core-api stored as `s3_key`). `resume_base64` is not used in bulk upload. + #### POST /internal/v1/match/resume-jd **Tag**: `Internal Service` -**Summary**: Internal candidate-JD matching score calculation (services/parsing-matching) +**Summary**: Score parsed resume against job requirements **Operation ID**: `internalMatchResumeJD` +**Called by**: Core API worker (after parse succeeds and the application row exists) + +Compares stored `parsed_jd` (from the job) against this resume's `parsed_resume`. Does not re-read the PDF. ```json Request: @@ -1099,14 +1248,17 @@ Request: "Content-Type": "application/json" }, "body": { - "parsed_jd": {}, - "parsed_resume": {} + "application_id": "555e4567-e89b-12d3-a456-426614174000", + "job_id": "444e4567-e89b-12d3-a456-426614174000", + "parsed_resume": {}, + "parsed_jd": {} } } Response 200: { "resume_score": 85.0, + "candidate_yoe": 5.0, "job_fit_analysis": {} } ``` diff --git a/docs/architecture/SYSTEM_DESIGN.md b/docs/architecture/SYSTEM_DESIGN.md index bc67f51..fea3f76 100644 --- a/docs/architecture/SYSTEM_DESIGN.md +++ b/docs/architecture/SYSTEM_DESIGN.md @@ -429,6 +429,44 @@ stateDiagram-v2 class rejected rejected ``` +### Workflow 2b: HR Bulk Resume Upload (S3 → parse → application → job-fit) + +HR bulk-uploads resumes for a published job. There is no batch poll API. Each file is processed independently. + +```mermaid +sequenceDiagram + actor HR as HR User + participant F as Frontend (SPA) + participant B as Core API + participant S3 as MinIO (resumes) + participant AI as Parsing & Matching + + HR->>F: Select resume files (PDF/DOCX) + F->>B: POST /api/v1/jobs/{id}/applications/upload-urls + B-->>F: s3_key + presigned upload_url per file + loop each file + F->>S3: PUT file to upload_url + end + F->>B: POST /api/v1/jobs/{id}/applications/bulk + B-->>F: 202 { job_id, queued } + + par per resume (independent) + B->>AI: POST /internal/v1/parse/resume + AI-->>B: parsed_resume + Note over B: Create/find candidate
Create application + B->>AI: POST /internal/v1/match/resume-jd + Note over AI: parsed_jd vs parsed_resume + AI-->>B: resume_score + job_fit_analysis + Note over B: Save score on application + end + + HR->>F: Open applicants + F->>B: GET /api/v1/jobs/{id}/applicants + B-->>F: Ranked list (scores fill in as workers finish) +``` + +Prerequisite: the job already has `parsed_jd` (parsed once on job create/publish). Bulk does not parse the JD. + ### Workflow 3: Session-Based Interview Scheduling, Auto Question Generation & Bot Screening ```mermaid diff --git a/docs/requirements/AI_SCREENING_REQUIREMENTS.md b/docs/requirements/AI_SCREENING_REQUIREMENTS.md index 2615cd1..a0cb1b6 100644 --- a/docs/requirements/AI_SCREENING_REQUIREMENTS.md +++ b/docs/requirements/AI_SCREENING_REQUIREMENTS.md @@ -127,27 +127,33 @@ sequenceDiagram ### Workflow Sequence +Public apply and HR bulk share the same per-resume AI chain. HR bulk has no form fields (email comes from parse). + ```mermaid sequenceDiagram - actor C as Candidate - participant F as Subdomain Portal - participant B as Core API Gateway + actor HR as HR + participant F as Frontend + participant B as Core API participant P as Parsing & Matching Engine - C->>F: Apply via {org}.ezscreen.io - F->>B: POST /api/v1/public/jobs/{id}/apply - B->>P: POST /internal/v1/parse/resume - P-->>B: Return parsed_resume JSON - B->>P: POST /internal/v1/match/resume-jd - P-->>B: Return job_fit_analysis & resume_score - Note over B: Create Application record (status = applied) - B-->>F: 201 Application Submitted + HR->>F: Upload resumes to S3 via presigned URLs + F->>B: POST /api/v1/jobs/{id}/applications/bulk + B-->>F: 202 { job_id, queued } + + par each resume independently + B->>P: POST /internal/v1/parse/resume + P-->>B: parsed_resume + Note over B: Create candidate + application + B->>P: POST /internal/v1/match/resume-jd + P-->>B: job_fit_analysis & resume_score + end ``` ### Functional Specifications * **FR-201 (Candidate Portal Application)**: Candidates apply via `POST /api/v1/public/jobs/{id}/apply` with `first_name`, `last_name`, `email`, `phone`, and `resume` file binary. -* **FR-202 (Resume Extraction)**: `services/parsing-matching` extracts `candidate_name`, `email`, `phone`, `experience_years`, `skills`, `education`, and `summary` into `parsed_resume` JSONB. -* **FR-203 (Matching Algorithm)**: Evaluates `parsed_resume` against `parsed_jd` and calculates `resume_score` (0.0 to 100.0) based on weighted formula: +* **FR-201b (HR Bulk Upload)**: HR uploads via `POST /api/v1/jobs/{id}/applications/upload-urls` then `POST /api/v1/jobs/{id}/applications/bulk`. No batch poll API. Results appear on `GET /api/v1/jobs/{id}/applicants`. +* **FR-202 (Resume Extraction)**: `services/parsing-matching` extracts `candidate_name`, `email`, `phone`, `experience_years`, `skills`, `education`, and `summary` into `parsed_resume` JSONB. Core-api creates the candidate user only after email is extracted. +* **FR-203 (Matching Algorithm)**: Evaluates `parsed_resume` against stored `parsed_jd` and calculates `resume_score` (0.0 to 100.0) based on weighted formula: $$\text{resume\_score} = (\text{skills\_score} \times 0.40 + \text{experience\_score} \times 0.35 + \text{education\_score} \times 0.25) \times 100$$ * **FR-204 (Denormalised Sorting Columns)**: Stores `resume_score` and `candidate_yoe` directly as typed columns on `applications` table for instant sorting without JSON parsing. diff --git a/services/ai-core-services/Dockerfile b/services/ai-core-services/Dockerfile index 33f0b25..9ae38aa 100644 --- a/services/ai-core-services/Dockerfile +++ b/services/ai-core-services/Dockerfile @@ -11,8 +11,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Copy pyproject.toml -COPY pyproject.toml . +# Copy project metadata and source needed for hatchling build +COPY pyproject.toml README.md ./ +COPY src ./src # Install dependencies using uv RUN uv pip install --system . diff --git a/services/ai-core-services/pyproject.toml b/services/ai-core-services/pyproject.toml index a6a4751..053a00f 100644 --- a/services/ai-core-services/pyproject.toml +++ b/services/ai-core-services/pyproject.toml @@ -22,5 +22,8 @@ dependencies = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +packages = ["src"] + [tool.uv] dev-dependencies = []