Skip to main content

CV & Job Extraction

The document processing pipelines use AWS Bedrock Claude to extract structured data from CVs and job descriptions. Both pipelines stream real-time progress via GraphQL subscriptions over WebSocket.

Overview

PipelineSubscriptionInputOutputTypical Time
CV ExtractionextractCVPDF/DOCX/Image URLStructured candidate profile30-60s
Job ParsingparseJobText or file URLStructured job requirements25-55s
Face ExtractionfaceExtractionPDF/Image URLCropped face images10-20s
Add CandidateaddCandidateCandidate data + job IDFull onboarding pipeline45-90s

All pipelines connect to wss://ai.aiqlick.com/graphql (production) or wss://ai-dev.aiqlick.com/graphql (development).

CV Extraction Pipeline

Processing Stages

  1. Document processing (OCR) -- PDF pages converted to images and processed via LLM-powered OCR in parallel (unlimited concurrency). Word documents (.docx) have text extracted directly. Images are OCR'd as-is.
  2. Parallel data extraction -- Five specialized extraction steps run concurrently via asyncio.gather(), each with its own schema and detailed prompt instructions:
  1. Result combination -- All step results are merged into a single candidate profile. Skills are normalized to flat array format, location is cross-referenced from metadata into contact, certifications are validated.
  2. Data transformation -- CandidateDataTransformer converts the extracted dict to domain models for persistence.

Extraction Steps Detail

Each extraction step calls Bedrock Claude via the Converse API tool-use pattern (structured JSON output). Every step includes:

  • Dedicated extra_prompt with field-specific instructions (what to look for, date formats, common patterns)
  • Timeout protection per step (configurable, default from cv_extraction_timeout setting)
  • Retry with exponential backoff on failure (configurable max_retries and retry_delay)
  • Empty-result validation -- experience extraction treats 0 results as a failure to trigger retries
StepSchemaRequired FieldsRetry on Empty
ContactContactSchema (object)name, emailNo (email missing is common)
SkillsSkillItem[] (array)skill nameNo (warns only)
EducationEducationItem[] (array)degreeNo
ExperienceExperienceItem[] (array)title, companyYes (retries if 0 entries)
Metadataobject (languages, certs, intro)noneNo
Performance

Parallel extraction typically completes in 15-30 seconds (all 5 LLM calls run concurrently). Single-step extraction (sequential: false) sends the full schema in one call but is less reliable for complex CVs.

Progressive Partial Events

Both pipelines stream a CVExtractionPartial event as each of the 5 parallel schema parses lands, instead of holding everything until the full extraction finishes. The frontend useCvExtractionGraphQL hook merges each slice into form state, so the Profile Builder populates incrementally — contact within ~3s, skills around 30s, experience 60s+ — instead of one big "everything appeared at once after 60s" jump.

Wire format: CVExtractionPartial { id, status: PROCESSING, step, partialJson }. step is one of the 5 extraction step names (contact, skills, education, experience, metadata) plus the special classification step that carries the pre-flight classifier verdict ({detected, confidence, reason, matches_expected}). partialJson is a JSON-encoded string the frontend JSON.parses into form state.

The implementation uses an asyncio producer/consumer queue: the extraction runs as a background task, each per-step result is pushed onto the queue via an on_step_complete callback, and the subscription generator drains the queue and yields CVExtractionPartial events as they arrive (see app/graphql/subscriptions/cv_extraction.py).

GraphQL Subscription

subscription ExtractCV($input: CVExtractionInput!) {
extractCV(input: $input) {
... on CVExtractionProcessing {
id
status
message
progress
}
... on CVExtractionPartial {
id
status
step
partialJson
}
... on CVExtractionSuccess {
id
status
parsed {
contact { name firstName lastName email phone linkedinUrl location }
skills { skill years proficiency }
experience { title company startDate endDate description location workType industry skills }
education { degree major institution startDate endDate gradYear gpa }
languages { name proficiency }
certifications { name institution year expiryDate credentialId }
workMode
introduction
cvUrl
}
rawText
fileUrl
}
... on CVExtractionFailure {
id
status
error { code message field context timestamp recoveryHint }
}
}
}

Input Parameters

{
"input": {
"id": "cv-unique-uuid",
"fileUrl": "https://storage.aiqlick.com/aiqlick-uploads/cvs/resume.pdf",
"sequential": true,
"companyId": "company-uuid",
"userId": "user-uuid"
}
}
FieldTypeRequiredDefaultDescription
idStringYes--Unique request identifier (use UUID)
fileUrlStringYes--S3/CloudFront URL. Supported: PDF, DOCX, PNG, JPG, JPEG
sequentialBooleanNotrueSequential extraction for better accuracy on multi-page docs
companyIdStringNonullCompany UUID for billing (employer path)
userIdStringNonullUser UUID for billing (job seeker path)
warning

If neither companyId nor userId is provided, the BillingGate is bypassed entirely -- no credits are charged and no AIOperationLog is written.

Extracted Data Model

ContactInfo

FieldTypeDescription
nameStringFull name
firstName / lastNameString?Parsed name parts
emailStringEmail address
phoneString?Phone number
linkedinUrlString?LinkedIn profile URL
profileLinkString?Other profile link (GitHub, portfolio)
locationString?Location / address

SkillItem

FieldTypeDescription
skillStringSkill name (e.g., "Python", "Project Management")
yearsIntYears of experience (default: 0)
proficiencyEnumNONE, BASIC, INTERMEDIATE, ADVANCED, EXPERT

Proficiency normalization (LLM → DB enum)

The DB-level SkillProficiency enum is NONE | BASIC | INTERMEDIATE | ADVANCED | EXPERT. The LLM occasionally returns synonyms (e.g. BEGINNER for entry-level skills, MASTER for top-tier) that aren't valid Postgres enum values. Both write paths normalize before insert so a synonym never crashes the SQL cast:

LLM outputNormalized to
BEGINNER, NOVICE, STARTERBASIC
MEDIUM, MODERATEINTERMEDIATE
HIGH, PROFICIENTADVANCED
MASTER, PROFESSIONALEXPERT
NONE, BASIC, INTERMEDIATE, ADVANCED, EXPERT(passthrough, case-insensitive)
anything else / nullINTERMEDIATE (sane default)

Implementations live in two places and produce identical results:

  • Backend (TypeScript)aiqlick-backend/src/talent-management/cv/cv-structured-write.helper.ts mapProficiency()
  • Background-tasks (Python)app/services/pipeline/infrastructure/repositories/cv_repository.py _map_proficiency()

Per-row writes are isolated: a malformed skill row logs a warning but the rest of the CV's skills still land. The Skill lookup is case-insensitive (LOWER(name) = LOWER($1)) and race-safe via INSERT … ON CONFLICT (name) DO NOTHING RETURNING id, so two concurrent CV parses for the same skill name don't drop the second one.

Structured-table write isolation

save_structured_cv_data writes 11 sibling tables (CvProfile, CvWorkExperience, CvEducation, CvCertification, CvLanguage, CvProject, CvPublication, CvAward, CvVolunteer, CvMetadata, CvJobPreference) plus save_cv_skills for the CVSkill join. Failures in any one table are caught and logged at ERROR level with exc_info=True (upgraded from WARNING in SUP-00177's wake) so partial-coverage failures show up in CloudWatch instead of being absorbed silently. The CV row + raw text are always preserved even if structured writes fail, so the data is recoverable via CV skills backfill.

ExperienceItem

FieldTypeDescription
titleStringJob title
companyStringCompany name
startDateStringStart date (YYYY-MM or YYYY-MM-DD)
endDateString?End date (null = current position)
descriptionString?Role description and achievements
locationString?Work location
workTypeEnumREMOTE, ONSITE, HYBRID, UNSPECIFIED
industryString?Industry sector
skills[String]Skills used in this role

EducationItem, LanguageItem, CertificationItem

Education includes degree, major, institution, gradYear, gpa. Languages include name and proficiency (BEGINNER through NATIVE). Certifications include name, institution, year, expiryDate, credentialId.

Progress Updates

ProgressMessage
0%Initializing CV extraction...
25%Processing document and extracting text...
50%Extracting structured data with AI...
75%Transforming extracted data...
100%(Success response emitted)

Job Parsing Pipeline

Mirrors the CV pipeline architecture: pre-flight classifier rejects non-job uploads, then 4 parallel converged extraction steps run via JobExtractor (app/services/pipeline/llm_services/job_extractor.py). Steps are deliberately converged — 4 LLM calls instead of the original 9 — to cut cost while keeping the parallelism win:

StepCovers
core_infoTitle, description, work mode, employment type, location, dates (start / due / application deadline), languages, priority, publish-online flag
requirements_qualificationsMust-have / nice-to-have requirements, qualifications
responsibilities_skillsKey responsibilities, daily tasks, required skills with weights
compensation_benefitsSalary, currency, rates, benefits

The classifier check + progressive partial event pattern is identical to CV: a JobParsingPartial event fires as each of the 4 steps lands, plus one step="classification" partial within ~2s of the upload (or text submit) carrying the classifier verdict ({detected, confidence, reason, matches_expected}). The frontend useJobParsingSubscription hook merges each slice into form state.

Pydantic schemas are the source of truth (May 2026)

background-tasks/app/services/pipeline/job_parsing/schemas.py defines one Pydantic v2 model per step (CoreInfoStep, RequirementsStep, ResponsibilitiesSkillsStep, CompensationBenefitsStep). Two things flow from these models:

  1. JSON Schema for the LLM prompt is generated via model_json_schema() — no more hand-rolled dicts that drift from the validation logic.
  2. Output validation runs the raw LLM dict through model_validate(). On ValidationError, the orchestrator strips the offending fields and retries; if validation still fails, the step degrades to model_construct() (empty defaults) so the rest of the pipeline still emits.

Only fields that map to the Job form / DB land in the schema — orphan extractions like companyName, industry, and free-text contact were stripped to save tokens (the LLM no longer "wastes" thinking on data we throw away). The post-step _combine_extraction_results() composes typed instances into a single JobExtractedData, with helpers (_range_mean, _normalize_currency, _normalize_date) handling the salary-range → midpoint and ISO-date normalisation.

When you add a new field, the only place to edit is the corresponding step model — codegen + validation follow automatically.

GraphQL Subscription

subscription ParseJob($input: JobParsingInput!) {
parseJob(input: $input) {
... on JobParsingProcessing { id status message progress }
... on JobParsingPartial { id status step partialJson }
... on JobParsingSuccess {
id
status
extractedData {
title description jobType workSite salary currency
rateIn rateOut startDate dueDate endDate location
priority publishedOnline
skills { name proficiency yearsExperience mustHave weight }
languages requirements responsibilities benefits
}
rawText fileUrl processingTime
}
... on JobParsingFailure {
id status
error { code message field recoveryHint }
}
}
}

Input Options

Job parsing accepts either raw text or a file URL:

// Text input (faster, preferred when text is available)
{ "input": { "id": "job-uuid", "text": "We are looking for a Senior Software Engineer..." } }

// File input
{ "input": { "id": "job-uuid", "fileUrl": "https://storage.aiqlick.com/.../description.pdf" } }

If both text and fileUrl are provided, fileUrl takes precedence.

JobSkillRequirement

FieldTypeDefaultDescription
nameString--Skill name
proficiencyEnumINTERMEDIATENONE, BASIC, INTERMEDIATE, ADVANCED, EXPERT
yearsExperienceInt?nullRequired years
mustHaveBooleantrueWhether the skill is mandatory
weightInt10Importance weight (1-100), used in matching

JobType and WorkSite Enums

JobType: FREELANCE_GIG, INTERNSHIP, TRIAL_AND_HIRE, DIRECT_HIRE

WorkSite: REMOTE, ONSITE, HYBRID, NOT_AVAILABLE, OFFSHORE

Face Extraction Pipeline

The faceExtraction subscription extracts face photos from CV documents using AWS Rekognition for face detection.

subscription FaceExtraction($input: FaceExtractionInput!) {
faceExtraction(input: $input) {
... on FaceExtractionProcessing { id status message progress }
... on FaceExtractionSuccess { id status faces { url confidence boundingBox } }
... on FaceExtractionFailure { id status error { code message } }
}
}

The pipeline converts PDF pages to images, runs Rekognition DetectFaces, crops detected face regions, and uploads cropped images to S3. The default minimum confidence threshold is 60%.

Add Candidate Pipeline

The addCandidate subscription orchestrates the full candidate onboarding flow: validate input, create User/Candidate/CV records, link to a job, and optionally trigger matching.

subscription AddCandidate($input: AddCandidateInput!) {
addCandidate(input: $input) {
... on AddCandidateProcessing { id status message progress }
... on AddCandidateSuccess { id status candidateId userId cvId }
... on AddCandidateFailure { id status error { code message recoveryHint } }
}
}

Required input fields: id, jobId, candidateData (with contact info), companyId. The pipeline validates UUIDs, contact information (name min 2 chars, email format, phone must contain digits), and candidate data structure before creating records.

Error Handling

Error Structure

All pipeline failures return a PipelineError:

FieldTypeDescription
codePipelineErrorTypeError category
messageStringHuman-readable description
fieldString?Field that caused the error (dot notation)
contextJSON?Additional context
timestampFloatUnix timestamp
recoveryHintString?Suggested fix

Error Categories

CategoryCodesRetryable
ValidationVALIDATION_ERROR, INVALID_INPUT, MISSING_FIELD, INVALID_FORMATNo
ProcessingDOCUMENT_ERROR, EXTRACTION_ERROR, AI_ERRORAI_ERROR only
InfrastructureTIMEOUT_ERROR, NETWORK_ERROR, STORAGE_ERROR, DATABASE_ERRORYes
ResourceNOT_FOUND, JOB_NOT_FOUND, USER_NOT_FOUNDNo
BillingINSUFFICIENT_CREDITS, PLAN_LIMIT_EXCEEDED, SUBSCRIPTION_INACTIVENo

Retry Strategy

For retryable errors, use exponential backoff: delay = min(1000 * 2^(attempt-1), 30000). The server-side timeout is 5 minutes (300 seconds).

React Integration

useCVExtraction Hook

import { useSubscription, gql } from '@apollo/client';
import { useState, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';

const CV_EXTRACTION_SUB = gql`
subscription ExtractCV($input: CVExtractionInput!) {
extractCV(input: $input) {
... on CVExtractionProcessing { id status message progress }
... on CVExtractionSuccess {
id status
parsed {
contact { name firstName lastName email phone location }
skills { skill years proficiency }
experience { title company startDate endDate description skills }
education { degree major institution gradYear }
languages { name proficiency }
certifications { name institution year }
workMode introduction
}
rawText
}
... on CVExtractionFailure { id status error { code message recoveryHint } }
}
}
`;

export function useCVExtraction({ onSuccess, onError }) {
const [extractionId, setExtractionId] = useState(null);
const [fileUrl, setFileUrl] = useState('');
const [progress, setProgress] = useState(0);
const [isExtracting, setIsExtracting] = useState(false);

useSubscription(CV_EXTRACTION_SUB, {
skip: !extractionId,
variables: { input: { id: extractionId, fileUrl, sequential: true } },
onData: ({ data }) => {
const update = data?.data?.extractCV;
if (update?.__typename === 'CVExtractionProcessing') setProgress(update.progress);
else if (update?.__typename === 'CVExtractionSuccess') { onSuccess?.(update.parsed); setIsExtracting(false); }
else if (update?.__typename === 'CVExtractionFailure') { onError?.(update.error); setIsExtracting(false); }
},
});

const startExtraction = useCallback((url: string) => {
setFileUrl(url);
setExtractionId(uuidv4());
setProgress(0);
setIsExtracting(true);
}, []);

return { startExtraction, progress, isExtracting };
}

File Storage

Files are stored in S3 and served via CloudFront CDN:

EnvironmentCDN URLS3 Bucket
Productionhttps://storage.aiqlick.comaiqlick-uploads
Developmenthttps://storage-dev.aiqlick.comaiqlick-dev-uploads

Supported formats: PDF (.pdf), Microsoft Word (.docx, .doc), Images (.png, .jpg, .jpeg).

Best Practices

  1. Always use UUIDs for request IDs to prevent conflicts across concurrent operations.
  2. Use sequential: true (default) for CV extraction -- this triggers the parallel multi-step extractor which is more reliable than single-step for complex CVs.
  3. Prefer text input for job parsing when the text is already available. File processing adds 5-15 seconds.
  4. Handle all three response types in every subscription: Processing (progress bar), Success (display results), Failure (show error with recovery hint).
  5. Pass companyId/userId in subscription variables for billing to work. Without them, BillingGate is silently skipped.
  6. Cache extraction results -- the same file produces the same structured output.
  7. Experience extraction retries automatically -- if the LLM returns 0 work experience entries, the step is retried with exponential backoff (up to max_retries attempts). Other steps accept empty results gracefully.