Skip to main content

Bulk CV Import

The Bulk CV Import feature allows companies to import multiple CVs at once from a ZIP file. The system extracts each CV using the existing CV extraction pipeline, creates User/Talent/CV records, and handles deduplication by email. All processing happens in the background with real-time progress streaming via GraphQL subscriptions.

Architecture

Per-CV Processing Flow

Supported File Types

ExtensionFormatNotes
.pdfPDF documentsPrimary format, processed via OCR
.docLegacy WordProcessed via python-docx
.docxModern WordProcessed via python-docx
.txtPlain textDirect text extraction

Skipped Files

The system automatically skips:

  • __MACOSX/ folders and ._ prefixed files (macOS ZIP artifacts)
  • Hidden files (starting with .) in any directory level
  • Files with unsupported extensions (images, spreadsheets, etc.)
  • Directory entries

GraphQL Mutation

The mutation returns BulkCvImportProcessing directly (not a union type). Validation errors are returned as standard GraphQL errors.

mutation CreateBulkCvImport($input: BulkCvImportInput!) {
createBulkCvImport(input: $input) {
id
status
message
progress
totalFiles
processedFiles
successCount
failedCount
skippedCount
}
}

Input

{
"input": {
"fileUrl": "bulk-cv-test/my-upload.zip",
"companyId": "company-uuid",
"userId": "user-uuid"
}
}
FieldTypeDescription
fileUrlStringS3 key or S3 URL of the uploaded ZIP file
companyIdStringCompany UUID — talents are added to this company's pool
userIdStringUser UUID — the user who initiated the import

Validation

The mutation performs synchronous validation before returning:

  1. URL validationfileUrl must be an S3 path (not an arbitrary HTTP URL)
  2. ZIP validation — Downloads the file and checks for valid ZIP magic bytes (PK\x03\x04)
  3. Size validation — Compressed size must be < 500 MB (configurable)
  4. ZIP bomb detection — Total uncompressed size must be < 2 GB
  5. File count — Counts supported CV files; rejects if 0 or > 500
  6. Creates DB record — Inserts BulkCvImport row with status=PENDING

Error Responses

ConditionError Message
Non-S3 URLfile_url must be an S3 path or storage URL
Not a ZIP fileFile is not a valid ZIP archive
ZIP too largeZIP file too large (X bytes, max Y bytes)
ZIP bomb detectedZIP uncompressed size too large
No CV filesNo supported CV files found in ZIP (supported: .pdf, .doc, .docx, .txt)
Too many filesToo many CV files (X, max 500)

GraphQL Subscription

subscription BulkCvImport($importId: String!) {
bulkCvImport(importId: $importId) {
... on BulkCvImportProcessing {
id
status
message
progress
totalFiles
processedFiles
successCount
failedCount
skippedCount
}
... on BulkCvImportSuccess {
id
status
totalFiles
successCount
failedCount
skippedCount
processingTimeMs
results {
filename
status
userId
talentId
cvId
email
errorMessage
}
}
... on BulkCvImportFailure {
id
status
error {
code
message
recoveryHint
}
}
}
}

Update Types

TypeWhenContains
BulkCvImportProcessingEvery 2s while processingProgress percentage, file counts, success/fail/skip counters
BulkCvImportSuccessTerminal: all or some succeededPer-file results with userId, talentId, cvId, email
BulkCvImportFailureTerminal: all failed or not foundError code, message, recovery hint

Progress Streaming

The subscription polls the database every 2 seconds. Updates are only emitted when processedFiles changes or a terminal status is reached.

Background Worker

File: app/services/bulk_cv/worker.py

The BulkCvImportWorker follows the same pattern as other background workers (Document Worker, Inbound Email Worker):

SettingDefaultEnv VariableDescription
Poll interval30sBULK_CV_POLL_INTERVALHow often to check for pending jobs
Max concurrent CVs3BULK_CV_MAX_CONCURRENTSemaphore limit for parallel CV processing
Max retries3BULK_CV_MAX_RETRIESMax attempts before giving up on a job
Max files per ZIP500BULK_CV_MAX_FILESMaximum CV files allowed in a single ZIP
Max ZIP size500 MBBULK_CV_MAX_ZIP_SIZE_BYTESMaximum compressed ZIP file size
EnabledtrueBULK_CV_IMPORT_ENABLEDFeature toggle

Job Claiming

Uses SELECT FOR UPDATE SKIP LOCKED to safely claim jobs in multi-worker scenarios:

  • Only claims jobs with status IN ('PENDING', 'EXTRACTING') and attemptCount &lt; maxRetries
  • Stale locks (older than 10 minutes) are automatically reclaimed
  • Each claim increments attemptCount

Status Lifecycle

Circuit Breaker

The worker uses a circuit breaker to prevent cascading failures:

  • Failure threshold: 5 consecutive failures
  • Reset timeout: 300 seconds (5 minutes)
  • Call timeout: 600 seconds (10 minutes)
  • When OPEN, the worker backs off for 60 seconds before retrying

Deduplication Logic

When processing each CV, the system deduplicates by email address:

Created Records

For each successfully processed CV:

RecordDetails
UserisOnboarding=false, isActive=false, isVerified=false. No invitation email sent.
TalentAdded to company's default TalentPool with source=BULK_IMPORT
CVLinked to both User and Talent, set as activeCvId
Structured CV tablesStructured extraction data stored for matching
SkillsExtracted and normalized skills saved to CVSkill table — proficiency synonyms (BEGINNER, MASTER, …) are mapped to the DB enum before insert; per-row failures are isolated. See Proficiency normalization on the CV-extraction page.
note

If a bulk import lands with cv.text populated but its CVSkill / structured tables empty, that's the exact pattern the CV skills backfill script remediates. The script re-runs the LLM extraction on the cached text without re-fetching from S3.

Database Schema

-- Enum
CREATE TYPE "BulkCvImportStatus" AS ENUM (
'PENDING', 'EXTRACTING', 'PROCESSING',
'COMPLETED', 'PARTIALLY_COMPLETED', 'FAILED'
);

-- Table
CREATE TABLE "BulkCvImport" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"companyId" UUID NOT NULL REFERENCES "Company"("id"),
"userId" UUID NOT NULL REFERENCES "User"("id"),
"status" "BulkCvImportStatus" DEFAULT 'PENDING',
"zipFileUrl" TEXT NOT NULL,
"totalFiles" INT DEFAULT 0,
"processedFiles" INT DEFAULT 0,
"successCount" INT DEFAULT 0,
"failedCount" INT DEFAULT 0,
"skippedCount" INT DEFAULT 0,
"results" JSONB,
"errorMessage" TEXT,
"processingTimeMs" INT,
"attemptCount" INT DEFAULT 0,
"lockedAt" TIMESTAMP,
"lockedBy" TEXT,
"createdAt" TIMESTAMP DEFAULT NOW(),
"updatedAt" TIMESTAMP DEFAULT NOW()
);

The results JSONB column stores per-file results:

[
{
"filename": "resume_john.pdf",
"status": "SUCCESS",
"user_id": "uuid",
"talent_id": "uuid",
"cv_id": "uuid",
"email": "john@example.com",
"error_message": null
},
{
"filename": "broken_cv.pdf",
"status": "FAILED",
"user_id": null,
"talent_id": null,
"cv_id": null,
"email": null,
"error_message": "No email found in CV"
}
]

File Structure

FilePurpose
app/graphql/types/bulk_cv_import.pyGraphQL types (Input, Processing, Success, Failure, FileResult)
app/graphql/mutations/bulk_cv_import.pyMutation: validates ZIP, creates DB record
app/graphql/subscriptions/bulk_cv_import.pySubscription: polls DB, streams progress
app/services/bulk_cv/__init__.pyModule init, exports worker singleton
app/services/bulk_cv/worker.pyBackground worker (polling, processing loop)
app/services/bulk_cv/repository.pyAsyncPG database queries
app/services/bulk_cv/zip_utils.pyZIP validation and extraction utilities
app/services/bulk_cv/talent_creator.pyShared user/talent/CV creation logic
tests/unit/services/test_bulk_cv_worker.py145 unit tests
tests/e2e/test_bulk_cv_import_e2e.py11 E2E tests against dev environment

Billing Integration

The worker calls BillingGate.pre_check(company_id, "CV_EXTRACTION", user_id=user_id) before each CV extraction. If billing blocks the operation (insufficient credits, plan limit exceeded), the CV is marked as SKIPPED (not FAILED) and processing continues with the remaining files.

Logging

Every step of the import pipeline is logged with structured extra fields for monitoring:

Event TypeDescription
bulk_cv_record_createdDB record created by mutation
bulk_cv_job_claimedWorker claimed a pending job
bulk_cv_downloading_zipStarting ZIP download
bulk_cv_zip_downloadedZIP downloaded, extracting files
bulk_cv_processing_startFile extraction complete, starting CV processing
bulk_cv_file_startStarting individual CV processing
bulk_cv_file_doneIndividual CV processing complete
bulk_cv_file_successCV processed successfully (includes user/talent/cv IDs)
bulk_cv_billing_blockedBilling pre-check blocked CV extraction
bulk_cv_extraction_failedCV extraction pipeline failed
bulk_cv_no_emailNo email found in parsed CV data
bulk_cv_job_completedJob finished with final status and counts
bulk_cv_job_failedJob failed with error

Testing

Unit Tests (145 tests)

pytest tests/unit/services/test_bulk_cv_worker.py -v -m unit

Covers: ZIP validation, file filtering (extensions, __MACOSX, hidden files, nested folders, unicode filenames, special characters), skills normalization, qualifications builder, dedup logic, progress tracking, error handling (S3 errors, billing blocks, extraction failures, missing emails), repository operations, subscription behavior, result dict key compatibility.

E2E Tests (11 tests)

pytest tests/e2e/test_bulk_cv_import_e2e.py -v -m e2e

Requires AWS SSO login and runs against ai-dev.aiqlick.com:

TestScenario
test_full_import_flow_with_pdfsFull pipeline: upload 2 PDFs → mutation → subscription → COMPLETED
test_dedup_reuses_existing_usersRe-import same ZIP, verify user IDs match
test_non_zip_file_rejectedPDF file (not ZIP) rejected with error
test_empty_zip_rejectedZIP with no CV files rejected
test_subscription_not_foundNon-existent import ID returns FAILED
test_single_pdf_importZIP with single PDF processes correctly
test_zip_with_nested_foldersCVs in deeply nested folders are found
test_zip_with_macosx_artifactsmacOS ZIP metadata is filtered out
test_zip_with_only_unsupported_filesZIP with only images/spreadsheets rejected
test_subscription_streams_incremental_progressProgress updates are monotonically increasing
test_mutation_returns_correct_total_filesFile count excludes non-CV files

Limitations

  • Text files (.txt) may fail CV extraction if the LLM cannot parse structured data from plain text
  • No password-protected ZIPs — encrypted ZIP files will fail extraction
  • Email required — CVs without an email address in the parsed data are marked as FAILED
  • No invitation emails — Users created via bulk import are inactive and unverified; no emails are sent