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 = []