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
| Pipeline | Subscription | Input | Output | Typical Time |
|---|---|---|---|---|
| CV Extraction | extractCV | PDF/DOCX/Image URL | Structured candidate profile | 30-60s |
| Job Parsing | parseJob | Text or file URL | Structured job requirements | 25-55s |
| Face Extraction | faceExtraction | PDF/Image URL | Cropped face images | 10-20s |
| Add Candidate | addCandidate | Candidate data + job ID | Full onboarding pipeline | 45-90s |
All pipelines connect to wss://ai.aiqlick.com/graphql (production) or wss://ai-dev.aiqlick.com/graphql (development).
CV Extraction Pipeline
Processing Stages
- 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.
- Parallel data extraction -- Five specialized extraction steps run concurrently via
asyncio.gather(), each with its own schema and detailed prompt instructions:
- 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.
- Data transformation --
CandidateDataTransformerconverts 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_promptwith field-specific instructions (what to look for, date formats, common patterns) - Timeout protection per step (configurable, default from
cv_extraction_timeoutsetting) - Retry with exponential backoff on failure (configurable
max_retriesandretry_delay) - Empty-result validation -- experience extraction treats 0 results as a failure to trigger retries
| Step | Schema | Required Fields | Retry on Empty |
|---|---|---|---|
| Contact | ContactSchema (object) | name, email | No (email missing is common) |
| Skills | SkillItem[] (array) | skill name | No (warns only) |
| Education | EducationItem[] (array) | degree | No |
| Experience | ExperienceItem[] (array) | title, company | Yes (retries if 0 entries) |
| Metadata | object (languages, certs, intro) | none | No |
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"
}
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | String | Yes | -- | Unique request identifier (use UUID) |
fileUrl | String | Yes | -- | S3/CloudFront URL. Supported: PDF, DOCX, PNG, JPG, JPEG |
sequential | Boolean | No | true | Sequential extraction for better accuracy on multi-page docs |
companyId | String | No | null | Company UUID for billing (employer path) |
userId | String | No | null | User UUID for billing (job seeker path) |
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
| Field | Type | Description |
|---|---|---|
name | String | Full name |
firstName / lastName | String? | Parsed name parts |
email | String | Email address |
phone | String? | Phone number |
linkedinUrl | String? | LinkedIn profile URL |
profileLink | String? | Other profile link (GitHub, portfolio) |
location | String? | Location / address |
SkillItem
| Field | Type | Description |
|---|---|---|
skill | String | Skill name (e.g., "Python", "Project Management") |
years | Int | Years of experience (default: 0) |
proficiency | Enum | NONE, 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 output | Normalized to |
|---|---|
BEGINNER, NOVICE, STARTER | BASIC |
MEDIUM, MODERATE | INTERMEDIATE |
HIGH, PROFICIENT | ADVANCED |
MASTER, PROFESSIONAL | EXPERT |
NONE, BASIC, INTERMEDIATE, ADVANCED, EXPERT | (passthrough, case-insensitive) |
| anything else / null | INTERMEDIATE (sane default) |
Implementations live in two places and produce identical results:
- Backend (TypeScript) —
aiqlick-backend/src/talent-management/cv/cv-structured-write.helper.tsmapProficiency() - 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
| Field | Type | Description |
|---|---|---|
title | String | Job title |
company | String | Company name |
startDate | String | Start date (YYYY-MM or YYYY-MM-DD) |
endDate | String? | End date (null = current position) |
description | String? | Role description and achievements |
location | String? | Work location |
workType | Enum | REMOTE, ONSITE, HYBRID, UNSPECIFIED |
industry | String? | 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
| Progress | Message |
|---|---|
| 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:
| Step | Covers |
|---|---|
core_info | Title, description, work mode, employment type, location, dates (start / due / application deadline), languages, priority, publish-online flag |
requirements_qualifications | Must-have / nice-to-have requirements, qualifications |
responsibilities_skills | Key responsibilities, daily tasks, required skills with weights |
compensation_benefits | Salary, 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.
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:
- JSON Schema for the LLM prompt is generated via
model_json_schema()— no more hand-rolled dicts that drift from the validation logic. - Output validation runs the raw LLM dict through
model_validate(). OnValidationError, the orchestrator strips the offending fields and retries; if validation still fails, the step degrades tomodel_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
| Field | Type | Default | Description |
|---|---|---|---|
name | String | -- | Skill name |
proficiency | Enum | INTERMEDIATE | NONE, BASIC, INTERMEDIATE, ADVANCED, EXPERT |
yearsExperience | Int? | null | Required years |
mustHave | Boolean | true | Whether the skill is mandatory |
weight | Int | 10 | Importance 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:
| Field | Type | Description |
|---|---|---|
code | PipelineErrorType | Error category |
message | String | Human-readable description |
field | String? | Field that caused the error (dot notation) |
context | JSON? | Additional context |
timestamp | Float | Unix timestamp |
recoveryHint | String? | Suggested fix |
Error Categories
| Category | Codes | Retryable |
|---|---|---|
| Validation | VALIDATION_ERROR, INVALID_INPUT, MISSING_FIELD, INVALID_FORMAT | No |
| Processing | DOCUMENT_ERROR, EXTRACTION_ERROR, AI_ERROR | AI_ERROR only |
| Infrastructure | TIMEOUT_ERROR, NETWORK_ERROR, STORAGE_ERROR, DATABASE_ERROR | Yes |
| Resource | NOT_FOUND, JOB_NOT_FOUND, USER_NOT_FOUND | No |
| Billing | INSUFFICIENT_CREDITS, PLAN_LIMIT_EXCEEDED, SUBSCRIPTION_INACTIVE | No |
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:
| Environment | CDN URL | S3 Bucket |
|---|---|---|
| Production | https://storage.aiqlick.com | aiqlick-uploads |
| Development | https://storage-dev.aiqlick.com | aiqlick-dev-uploads |
Supported formats: PDF (.pdf), Microsoft Word (.docx, .doc), Images (.png, .jpg, .jpeg).
Best Practices
- Always use UUIDs for request IDs to prevent conflicts across concurrent operations.
- Use
sequential: true(default) for CV extraction -- this triggers the parallel multi-step extractor which is more reliable than single-step for complex CVs. - Prefer text input for job parsing when the text is already available. File processing adds 5-15 seconds.
- Handle all three response types in every subscription: Processing (progress bar), Success (display results), Failure (show error with recovery hint).
- Pass
companyId/userIdin subscription variables for billing to work. Without them, BillingGate is silently skipped. - Cache extraction results -- the same file produces the same structured output.
- Experience extraction retries automatically -- if the LLM returns 0 work experience entries, the step is retried with exponential backoff (up to
max_retriesattempts). Other steps accept empty results gracefully.