Skip to main content

Matching Engine

The matching engine calculates compatibility scores between job seekers and job postings, producing both a numeric score (0--100) and a detailed human-readable report. It is one of the core differentiating features of the AIQLick platform.

Service: background-tasksapp/services/matching/unified_scoring/

Architecture Overview

Scoring Dimensions

Every match is evaluated across seven weighted dimensions. Each dimension produces a sub-score from 0 to 100, and the final score is the weighted sum of all dimensions. The three async dimensions (skills, location, languages) are evaluated in parallel via asyncio.gather() for lower latency; the remaining sync dimensions run sequentially.

DimensionBase WeightDescription
Skills55%Exact skill ID matching combined with NLP semantic similarity, per-skill proficiency + years comparison, recency factor, category-aware scoring
Experience15%Career progression quality, breadth, and seniority fit (weighted average of required years, not per-skill — per-skill years are handled by the Skills dimension)
Salary10%Compatibility analysis using market benchmarks
Location8%Remote/onsite/hybrid compatibility, geographic distance, relocation willingness
Languages5%Required language proficiencies vs. candidate capabilities
Job Type4%Full-time, part-time, contract, freelance alignment
Availability3%Start date feasibility, notice period consideration

Dynamic Weight Adjustment

Weights are not static. The engine adjusts weights based on multiple job characteristics through a series of cumulative adjustments, then normalizes the result so all weights sum to 1.0.

Weight Profiles

Ten predefined profiles serve as reference points for the adjustment logic:

ProfileSkillsExpSalaryLocationLanguagesJob TypeAvailability
Base55%15%10%8%5%4%3%
Technical60%15%10%6%4%3%2%
Senior45%30%10%6%4%3%2%
Entry Level55%10%8%12%5%5%5%
Remote58%18%12%2%4%3%3%
Onsite45%15%8%22%4%3%3%
International45%15%8%12%14%3%3%
Startup55%12%6%10%4%7%6%
Enterprise45%20%15%8%5%4%3%
Contract60%15%10%5%4%3%3%
Consulting50%25%10%6%4%3%2%

Adjustment Factors

Eight adjustment functions are applied cumulatively to the base weights:

AdjustmentTriggerEffect
Job TypeFREELANCE_GIG, CONTRACT, INTERNSHIP, DIRECT_HIREInternships: skills +8%, experience -10%. Contracts: skills +5%, availability +3%
Worksite TypeREMOTE, ONSITE, HYBRIDRemote: location drops to 2%. Onsite: location +8%, skills -3%
Seniority LevelTitle keywords: senior, lead, junior, manager, directorSenior: experience +10%, skills +5%. Junior: skills +8%, experience -8%
Skill Requirements>10 skills required, >70% must-have, multi-domainHigh requirements: skills +5%, experience +3%
Company CharacteristicsCompany name keywords: startup, labs, corp, bankStartup: skills +5%, salary -5%. Enterprise: experience +5%
Location Requirementsinternational, global, tech hub citiesInternational: languages +5%. Tech hubs: salary +3%
UrgencyTitle/description: urgent, ASAP, immediateAvailability +5%, skills +3%, experience -3%
User ProfileUser's career stage, relocation willingnessEarly career (<2 yrs): skills +3%. Senior (>10 yrs): experience +3%. Willing to relocate: location -3%

After all adjustments, weights are clamped to zero (negative values from accumulated adjustments are floored) and then normalized so they sum to exactly 1.0. This means two different jobs produce different score breakdowns for the same candidate, reflecting what actually matters for each position.

Two-Tier Skill Matching

Skill matching uses a two-tier approach to balance precision and recall:

Tier 1 -- Exact Match: Direct comparison of skill database IDs. If a job requires "React" (skill ID 142) and the candidate has "React" (skill ID 142), this is an exact match scoring full points. This tier is fast and deterministic.

Tier 2 -- Semantic Similarity: For skills without an exact ID match, the engine uses NLP-based semantic similarity. This catches cases like:

  • "React.js" vs. "React" (naming variations)
  • "Machine Learning" vs. "ML Engineering" (abbreviations and related terms)
  • "PostgreSQL" vs. "Database Administration" (related but distinct skills)

Tier 1 matches always score higher than Tier 2 matches, ensuring that exact skill alignment is valued over approximate similarity.

Match Types and Scoring

Match TypeConfidenceScore Contribution
exact_id1.0 (ID match)Full points
name_based1.0 (exact name)Full points
semantic>= 0.85Reduced by transferability factor (0.5x)
similar0.70 -- 0.85Minimal contribution
none< 0.70Not counted

Proficiency Scoring

Skills are evaluated on a 4-level proficiency scale:

LevelValueEffect
BASIC1-25% per level below requirement
INTERMEDIATE2Default for unspecified levels
ADVANCED3+10% per level above requirement
EXPERT4+10% per level above requirement
Skill Level Defaults

When CV extraction produces skill levels of 0 or empty, the scoring system defaults them to INTERMEDIATE (level 2). This prevents penalizing candidates whose CVs don't explicitly state proficiency levels.

Recency Factor

Skills are penalized for staleness based on CVSkill.lastUsed (a year string, e.g. "2025"):

StalenessFactorEffect
Within 2 years1.00Full credit
3 years ago0.95-5%
4 years ago0.90-10%
5+ years ago0.75-25% (capped)
Unknown (lastUsed is null)1.00No penalty

Category-Aware Scoring

CVSkill.category distinguishes "technical" from "soft" skills. Soft skills (e.g., "communication", "leadership") receive a gentler proficiency penalty — their proficiency score is floored at 0.6 instead of the standard minimum. This reflects that soft skill proficiency is harder to quantify and shouldn't dominate gap severity.

Quality Bonuses

Small bonuses are applied and capped at 5% total:

BonusMaxCondition
Skill diversity+2%Candidate has more skills than required
Semantic confidence+2%Multiple matches with 0.8+ confidence
Experience quality+1%Exceeds years-of-experience requirements

Zero Skills Penalty

If a candidate has no matching skills at all, the skill score is capped at 0--10% (allowing only a small margin for transferable semantic matches). Candidates missing must-have skills score near 0% on the skill dimension.

Component Scoring Details

Experience Analysis

Evaluates career-level signals that per-skill matching cannot capture: career progression quality, breadth of experience, and seniority fit. Uses statistical models via StatisticalAnalyzer.

Per-Skill vs Career Experience

Per-skill years comparison (e.g., "requires 3 years Python") is handled by the Skills dimension — each matched skill's contribution includes a 40% years-score factor. The Experience dimension focuses on aggregate career quality using the weighted average of required years across all job skills, rather than max().

QualityBase ScoreDescription
Excellent95Relevant years >= 80% of required, strong progression
Good85Solid experience with relevant background
Average70Meets basic requirements
Below average55Partial experience match
Poor35Significant experience gap

Score adjustments: Relevance ratio (+-20), skill diversity (+-10), progression rate (+-15), years shortfall (-5 per missing year).

Salary Analysis

Uses market benchmarks for 5 role categories across 6+ locations with percentile data:

DifferenceScoreRisk Level
Within 5%95Very low
5--10%90Low
10--20%80Moderate
20--30%70High
30%+55Very high
No data (both sides)50Truly neutral
Salary Field Detection

The weight redistribution checks for salary, salaryMin, or salaryMax on the job side. If any salary field is present AND the user has expectedSalary, the salary component is included in scoring. Otherwise it is excluded and its weight redistributed.

Location Analysis

Uses the Haversine distance formula with a database of 50+ major cities worldwide:

ScenarioScore
REMOTE worksite95 (location mostly irrelevant)
Same city, ONSITE90
Within 25km, ONSITE80
Within 50km, ONSITE60
>50km, willing to relocate60 + bonus (15--45)
HYBRID adjustments+5--10 over ONSITE thresholds

Null-safe handling: The engine uses (job_data.get('workSite') or '').upper() to safely handle jobs where workSite is None rather than an empty string.

Language Analysis

  • Match types: Exact + variant detection (e.g., "English" vs "English (Native)")
  • Criticality rules: English in international roles = critical, first listed language = critical, <=2 required = all critical
  • Penalties: -20 per critical missing, -10 per missing language
  • Bonuses: +3 per extra language beyond requirements (max 10)

Job Type Analysis

MatchScoreCompatibility
Direct match100Excellent
Similar type (e.g. full-time ↔ permanent)80Good, flexibility needed
No match40--65Depends on role seniority

Availability Analysis

TimingScore
Same day100
Within 7 days95
Within 30 days85
Early availability75
Minor delay (<=14 days late)65--75
Significant delay (<=30 days)45
Major delay (>30 days)25

Contract and freelance roles get +10 flexibility bonus.

Missing Data Handling (Weight Redistribution)

When a scoring dimension lacks sufficient data, it is excluded from the weighted average rather than receiving a default score. The weights of the remaining dimensions are proportionally redistributed to ensure the final score reflects only available data.

Data Requirements Per Component

ComponentHas Data When
SkillsJob has at least one JobSkillRequirement row. If the job has zero JSRs, the description-based skill extractor fires first (see Description-Based Skill Extraction) — skills are only excluded from redistribution when both the JSR table and the extractor come up empty.
ExperienceAny job skill has yearsExperience > 0
SalaryJob has salary or salaryMin or salaryMax, AND user has expectedSalary
LocationJob has location or workSite set
LanguagesJob has language requirements
Job TypeJob has jobType AND user has preferredJobTypes
AvailabilityJob has startDate AND user has availabilityDate
Skills-Always-Count Fix (SUP-00019)

Skills used to be hardcoded as "has data = always True", which meant a job with zero JSRs kept its 55% skills weight multiplied by the analyzer's neutral 50/100 — anchoring every candidate at a ~51% score. The April 2026 fix changed this to has_real_data["skills"] = bool(job_skills) so missing-skill-data triggers redistribution like every other component. Combined with the description-based extractor that synthesises JSRs from the job description, the 51% cluster is eliminated even for recruiters who skipped the "add skill requirements" step.

Components without data get weight = 0.0 and weighted_score = 0.0. Remaining components are rescaled: new_weight = old_weight / total_real_weight.

Why This Matters

Without redistribution, a job with no salary range, no language requirements, and no start date would score those dimensions at 100% by default — inflating the overall score artificially. A candidate with only skill matches might appear as an 80% match when the real signal is closer to 45%.

User Eligibility

The matching engine scores all users with an active CV:

CriterionRequirement
Has active CVactiveCvId IS NOT NULL

This is the only filter. Users added via inbound CV email, createOnboardedUser, or self-signup all get match scores as long as they have a CV — regardless of isActive, isVerified, or isOnboarding status.

Previous Filters Removed

Earlier versions required isActive = true, isVerified = true, isOnboarding = true, and qualifications IS NOT NULL. These excluded legitimate talent profiles (e.g., talents added by employers via inbound email). The single activeCvId criterion ensures full coverage. User.qualifications was dropped from the schema entirely in SUP-00019 (April 2026) — the engine now reads CV data exclusively from the 11 structured CV tables.

Unified Scoring and Reporting

The engine generates both the numeric score and the detailed report in a single pass. This avoids the overhead of separate scoring and explanation phases, and ensures the report always reflects exactly the same logic that produced the score.

The report includes:

  • Overall compatibility assessment with match category (excellent/good/fair/poor)
  • Per-dimension score breakdown with raw scores, weights, and weighted scores
  • Key strengths (where the candidate exceeds requirements)
  • Gaps identified (where requirements are not met)
  • Actionable recommendations
  • Executive summary and methodology explanation
  • Contextual insights from NLP skill gap analysis

Results are written to UserJobMatchScore (live) and — when scoring is triggered for a Candidate with a specific CV — also to MatchScore (snapshot). See Candidate Match Snapshots for the dual-write contract.

Column Structure (both tables)

ColumnContents
scoreNumeric score 0--100
detailsFull scoring breakdown: basicScores (llmScore, skillScore, semanticScore, contextScore, missingSkills, educationMatch), detailedReport (overall_match_score, scoring_breakdown, strengths, gaps, recommendations, skill_analysis, experience_analysis, location_analysis, salary_analysis, fit_assessment), plus the raw engine output (component_scores, weights, weighted_scores, unified_report, scoring_method, skill_analysis_summary)

The basicScores / detailedReport keys are the shape the frontend consumes directly; the raw engine fields remain in the payload for backward compatibility and for downstream tooling.

Qualifications Column Removed (SUP-00019)

UserJobMatchScore.qualifications used to carry a per-match skills snapshot. It was dropped from the schema in April 2026 — historical rows stored stringified JSON that failed the GraphQLJSONObject scalar's type check, and the snapshot was redundant with the 11 structured CV tables that are now the single source of truth. The scorer reads user skills / years / languages at run time via the _hydrate_user_cv_fields() helper (see Data Hydration) and MatchingDatabase.get_user_skills(), both of which query the structured tables directly.

JSON Field Serialization

Background-tasks writes details using json.dumps() with AsyncPG, which can result in double-encoded JSONB values (a JSON string instead of a JSON object). The backend resolver for UserJobMatchScore includes a defensive @ResolveField('details') method that detects string values and calls JSON.parse() before returning them to GraphQL. This ensures the GraphQLJSONObject scalar always receives a proper object regardless of how the data was stored. The same pattern applies to MatchScore.details (CV-to-job scores).

Data Hydration

The scorer does not rely on legacy JSON blobs. Before calling the unified engine, MatchingService._score_pair() enriches user_data with three fields sourced from the structured CV tables via cv_query_repository.get_cv_data_by_user_id():

FieldSourceConsumer
yearsOfExperienceCvProfile.yearsOfExperiencecomponent_analyzers._extract_user_experience_years() + dynamic_weighting (career-level adjustment)
languagesCvLanguage rowscomponent_analyzers._extract_user_languages()
skills (already in user_skills arg)CVSkill via MatchingDatabase.get_user_skills()skill_analyzer

Hydration happens once per scoring call and runs with a DB_FETCH_TIMEOUT guard. Failures degrade gracefully: the analyzers fall back to whatever data is already on user_data (typically empty), so a transient RDS blip never crashes the scorer.

Score Validation

Every score is validated for authenticity:

  • Component weighted scores are summed and compared against the reported overall score
  • Low variance detection flags when all components score within a 10-point range (may indicate data limitations)
  • Score distribution is tracked across calculations to detect artificial clustering

Background Processing

Match Score Generator

The Match Score Generator is a background worker that runs on a 5-second cycle:

  1. Fetches active users (up to 100) and active jobs (up to 100)
  2. Batch-checks all user-job pairs for existing scores in a single query (replaces per-pair checks)
  3. For missing pairs, calculates match scores via the unified scoring engine
  4. Persists results to UserJobMatchScore
  5. Sleeps until the next cycle

The get_unscored_pairs() query uses CROSS JOIN + NOT EXISTS to find gaps across all eligible users and all active (non-draft) jobs in a single query:

SELECT u.id, j.id
FROM "User" u CROSS JOIN "Job" j
WHERE u."activeCvId" IS NOT NULL
AND j.status != 'DRAFT'
AND NOT EXISTS (
SELECT 1 FROM "UserJobMatchScore" ujms
WHERE ujms."userId" = u.id AND ujms."jobId" = j.id
)
ORDER BY u."updatedAt" DESC, j."createdAt" DESC
LIMIT 100

This ensures that match scores are pre-computed for every user-job combination. Newest users and jobs are prioritized first. Over successive 5-second cycles, all pairs are eventually covered.

Circuit Breaker Behavior

If the scoring engine encounters repeated failures (e.g., database connectivity issues), the circuit breaker trips and the worker enters a backoff period. It will retry with exponential delays before resuming normal operation, preventing resource exhaustion during outages.

Score Invalidation

Match scores are automatically invalidated (deleted) when underlying data changes. The MatchScoreInvalidationListener listens for domain events and deletes stale scores so the background worker regenerates fresh ones on its next 5-second cycle.

The cv.updated event itself is fired by the @EmitCvUpdatedAfter method decorator on every structured-CV mutation in CvStructuredMutationService — see Structured CV Tables → @EmitCvUpdatedAfter decorator for the wiring detail. Before that decorator existed, 21 mutation methods (certification / language / project / publication / award / volunteer × add/update/remove on the job-seeker side) silently skipped the emit, so edits to those sections never invalidated match scores. Tracked under SUP-00015.

EventConditionScores Deleted
cv.updatedCV is the active CV for a user or talentUserJobMatchScore (by userId) + MatchScore (by cvId)
cv.parsedSame as above (structured data rewritten)UserJobMatchScore (by userId) + MatchScore (by cvId)
cv.active.changedUser switches their active CVUserJobMatchScore (by userId) + MatchScore (previous cvId)
cv.deletedAny CV deletionMatchScore (by cvId) + UserJobMatchScore (if user's active CV)
job.updatedJob information changesUserJobMatchScore (by jobId) + MatchScore (by jobId)
Active CV Check

For CV events (cv.updated, cv.parsed), the listener first checks whether the CV is anyone's active CV — via User.activeCvId (job seeker context) or Talent.activeCvId (company context). If the CV is not active, no scores are invalidated, avoiding unnecessary recalculation.

Implementation: src/job-management/pipeline/match-score-invalidation.listener.ts

Single Pipeline Architecture

All matching goes through one scoring pipeline — no exceptions. Every entry point delegates to MatchingService.calculate_match_score() which calls _score_pair()UnifiedScoreCalculator. Same criteria, same weights, same algorithm everywhere.

The pipeline thin wrappers (app/services/pipeline/matching/) only handle input resolution (candidateId → userId lookup, onboarding checks) before delegating to the unified engine. They contain zero scoring logic.

_score_pair() always writes UserJobMatchScore for the (userId, jobId) pair. When an optional cv_id is passed (by the candidate.created listener and the backfill endpoint), it also upserts a MatchScore row for the (jobId, cvId) pair — the snapshot used by Candidate.matchReport. See Candidate Match Snapshots.

On-Demand Matching

Three GraphQL subscriptions provide on-demand matching with real-time progress updates. All use the same unified scoring engine as the background generator:

SubscriptionInputWrapper LogicScoring
basicMatchingcandidateId + jobIdResolves candidateId → userId via Talent tableMatchingService.calculate_match_score()
userJobMatchinguserId + jobIdChecks onboarding statusMatchingService.calculate_match_score()
candidateJobMatchingcandidateId + jobIdResolves candidateId → userId via Talent tableMatchingService.calculate_match_score()

On-demand matching triggers immediate scoring for a specific pair. The background generator handles bulk coverage for all pairs over time.

Candidate Match Snapshots

The matching engine persists scores to two distinct tables, both written from the same _score_pair() computation. The distinction is about lifecycle, not algorithm.

TableKeyLifecycleRead By
UserJobMatchScore(userId, jobId)Live. Invalidated when the user's active CV or the job changes; regenerated by the 5-second background loop.Job-seeker dashboards, recommended-talents grids, getUserJobMatchScore query
MatchScore(jobId, cvId)Snapshot. Keyed to the Candidate's CV (usually a frozen clone — see Application CV Snapshot). Immutable for the life of the snapshot CV.Candidate.matchReport, Candidate.matchScore field resolvers (employer view)

Why two tables

A job seeker's live score must refresh as their CV improves. But a Candidate — a specific application attached to a specific job at a specific point in time — needs a score that is frozen at add-time, matching the frozen CV snapshot the employer actually reviewed. If the talent later updates their live CV, the recruiter shouldn't see a moving target on a candidate already in their pipeline.

The snapshot contract holds naturally because snapshot CVs never receive cv.updated events (they are immutable clones), so MatchScore rows keyed by a snapshot cvId are never invalidated. The MatchScoreInvalidationListener is unchanged — it deletes by cvId and the snapshot cvIds simply never appear in its input.

Dual-write flow

The backend listener is best-effort: HTTP failures are logged but never propagate to the user-facing mutation, so candidate creation stays fast. A missed write is eventually caught by the background loop for the live row and by the historical backfill endpoint for the snapshot row.

Endpoint: POST /api/v1/matching/candidate

Called by the backend listener on every candidate.created event.

PropertyValue
Body{ "candidateId": "<uuid>" }
AuthNone at the route (internal; the service is reachable only via the backend's AI_SERVICE_URL)
BehaviourLoads candidate → user → job → snapshot CV → runs _score_pair(..., cv_id=candidate.cvId) → dual-writes both tables
BillingConsumes employer credits via BillingGate when companyId is derivable from the job
Failure modes404 if candidate / cv / user / job not found. 500 on scorer failure (listener swallows it)

Historical backfill

New candidates (post-deploy) populate automatically via the event path above. Historical candidates — those created before the event wiring existed — do not have a MatchScore row and need a one-time backfill.

PropertyValue
EndpointPOST /api/v1/matching/backfill-candidates
AuthX-Internal-Token: $INTERNAL_ADMIN_TOKEN header required. The middleware returns 503 "INTERNAL_ADMIN_TOKEN is not configured on this environment; admin endpoints are disabled" if the env var is missing — fail-closed by design
Query paramsdry_run (default true) · batch_size (1–50, default 10) · job_id (scope to one job) · limit (cap total this run)
QuerySELECT c.id, c.userId, c.jobId, c.cvId FROM Candidate c WHERE c.cvId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM MatchScore ms WHERE ms.jobId = c.jobId AND ms.cvId = c.cvId) ORDER BY c.createdAt DESC LIMIT $1
ConcurrencyBounded asyncio.gather per batch
IdempotencyUPSERT via ON CONFLICT (jobId, cvId) — safe to re-run after partial failures
SkipsCandidates on jobs with status = DRAFT are reported as errors but do not count as failures; drafts are intentionally unscored

Staged rollout recipe:

TOKEN=$(aws secretsmanager get-secret-value \
--profile Administrator-842697652860 --region eu-north-1 \
--secret-id aiqlick-backend/production \
--query SecretString --output text \
| python3 -c "import sys,json; print(json.load(sys.stdin)['INTERNAL_ADMIN_TOKEN'])")

# 1. Dry-run — get the backlog size, no credit spend
curl -X POST "https://ai.aiqlick.com/api/v1/matching/backfill-candidates?dry_run=true" \
-H "X-Internal-Token: $TOKEN"

# 2. Tiny real run — verify shape of a few written rows
curl -X POST "https://ai.aiqlick.com/api/v1/matching/backfill-candidates?dry_run=false&limit=20" \
-H "X-Internal-Token: $TOKEN"

# 3. Drain — no limit, batch_size=10
curl -X POST "https://ai.aiqlick.com/api/v1/matching/backfill-candidates?dry_run=false&batch_size=10" \
-H "X-Internal-Token: $TOKEN"

Required environment

Two env vars on the backend side carry this wiring:

VariableServicePurpose
AI_BACKEND_GRAPHQL_URLaiqlick-backendInternal URL the listener uses to reach bg-tasks via the internal ALB. Set to http://bg-{env}.aiqlick.local:8000/graphql in the ECS task definition (Cloud Map name routed by the internal ALB). The legacy AI_SERVICE_URL env var pointing at https://ai.aiqlick.com was retired with Phase 7.
INTERNAL_ADMIN_TOKENbackground-tasksShared secret for the /backfill-candidates route. 32+ chars recommended. Absent = admin endpoints fail-closed with 503

Both are stored in AWS Secrets Manager (aiqlick-backend/production, aiqlick-backend/development) and injected into the ECS tasks via the _BUNDLE_JSON model — see Secrets Management. To pick up a secret change on a running service, force a redeploy with aws ecs update-service --force-new-deployment; the deployment circuit breaker auto-rolls back on health-check failure.

Score Interpretation

RangeCategoryMeaning
85--100ExcellentStrong match across most dimensions
70--84GoodSolid match with minor gaps
50--69FairPartial match, notable gaps exist
0--49PoorSignificant mismatches

Worked Example

Scenario: Python developer (8 yrs experience) applying for Senior Developer role (remote-friendly, $120k)

Dynamic Weight Adjustments:
Base → Senior title (+10% exp) → Remote worksite (location → 2%) → Normalize

Component Scores (after redistribution):
1. Skills: 75 × 0.579 = 43.4 (semantic match, level below requirement)
2. Experience: 85 × 0.158 = 13.4 (exceeds requirement, strong progression)
3. Salary: 80 × 0.105 = 8.4 (within range, $110k vs $120k)
4. Location: 90 × 0.084 = 7.6 (remote-compatible)
5. Languages: — × 0.000 = 0.0 (no requirement → excluded)
6. Job Type: 75 × 0.042 = 3.2 (prefers full-time, role is direct-hire)
7. Availability: 80 × 0.032 = 2.6 (can start in 2 weeks)

Final Score: 78.6 → "GOOD" match

Implementation Files

Matching Service (app/services/matching/matching_service.py)

The service uses consolidated helpers to avoid duplication:

MethodPurpose
_score_pair(cv_id=None)Core scoring — hydrates user_data, calls unified scorer, builds details, dual-writes (always UserJobMatchScore; plus MatchScore(jobId, cvId) when cv_id is passed)
_hydrate_user_cv_fields()Pulls yearsOfExperience + languages from the 11 structured CV tables and merges them into user_data before scoring. Replaced the deprecated _build_cv_snapshot() (SUP-00019)
_fallback_job_skills_from_description()When a job has 0 JobSkillRequirement rows, delegates to skill_extractor.extract_skills_from_description() to mine virtual JSRs from the job's description text. See Description-Based Skill Extraction
_build_details()Builds the details dict from a unified report
_build_result()Builds the standard result dict (user_id, job_id, score, etc.)
_score_user_for_job()Fetches user skills, delegates to _score_pair() (used by batch_match_users_to_job)
_score_job_for_user()Fetches job data/skills, applies the description fallback, delegates to _score_pair() (used by batch_match_jobs_to_user)

Timeout constants are defined at module level: SCORING_TIMEOUT (30s), DB_FETCH_TIMEOUT (10s), DB_SAVE_TIMEOUT (5s), BATCH_TIMEOUT (60s), BATCH_CONCURRENCY (10).

Description-Based Skill Extraction

File: app/services/matching/unified_scoring/skill_extractor.py

A job that ships with zero JobSkillRequirement rows used to anchor every candidate at the neutral skills score (50/100 × 0.55 weight = +27.5), producing the "everyone at 51%" cluster observed on dev before SUP-00019. The extractor fixes this by mining skills from the job description at scoring time, then persisting them as real JSRs so subsequent scoring runs hit the normal path (no fallback, no LLM cost).

PropertyValue
When it firesjob_skills == [] AND job.description is non-empty
Primary pathBedrock Haiku via BedrockParserService.parse_with_schema() — extracts canonical skill names + proficiency + years + must-have
Fallback pathWhole-word regex match against the Skill table catalog (loaded once per process, 10 min cache)
PersistenceEach resolved skill upserts into Skill then inserts into JobSkillRequirement with mustHave, proficiency, yearsExperience, weight populated. Duplicate-safe via the @@unique([jobId, skillId]) index on JSR
CachingModule-level _job_cache keyed by (job_id, description_hash) for 10 min — one billable LLM call per job per cache window
BillingBillingGate.pre_check + check_and_consume under operation type JOB_PARSING when company_id is available (resolved from job.companyId). Unmetered when billing context is absent
Cost amortizationBecause extracted JSRs are persisted, future scoring cycles for the same job hit get_job_skills() → returns real rows → the fallback never runs again

This also means recruiters see the auto-extracted skills in the standard job-detail UI and can edit or delete them like any other skill requirement.

Unified Scoring (app/services/matching/unified_scoring/)

FilePurpose
score_calculator.pyMain orchestrator — integrates all components, applies redistribution
dynamic_weighting.py11 weight profiles + 8 cumulative adjustment functions
skill_analyzer.pyTwo-tier skill matching (exact ID + NLP semantic) with recency + category
component_analyzers.py6 non-skill analyzers (experience, salary, location, language, job type, availability)
data_models.pyDataclasses for all scoring structures
statistical_analyzer.pyMarket benchmarks, experience profiles, percentile calculations
geographic_calculator.pyHaversine distance formula, commute feasibility
nlp_skill_matcher.pySemantic similarity scoring
report_generator.pyExecutive summary, detailed analysis, recommendations

Pipeline Thin Wrappers (app/services/pipeline/matching/)

These services are called by the GraphQL subscriptions. They handle input resolution only, then delegate to the unified engine:

FileSubscriptionWrapper Logic
service.pybasicMatchingResolves candidateId → userId
user_job_matching_service.pyuserJobMatchingChecks onboarding status
candidate_job_matching_service.pycandidateJobMatchingResolves candidateId → userId

Backend (NestJS)

FilePurpose
match-score.service.tsCRUD for MatchScore table (CV-to-job snapshots)
match-score.resolver.tsGraphQL queries + @ResolveField('details') with defensive JSON parse
user-job-match-score.service.tsCRUD for UserJobMatchScore table (live user-to-job scores)
user-job-match-score.resolver.tsGraphQL queries + @ResolveField('details') with defensive JSON parse
match-score-invalidation.listener.tsEvent-driven invalidation on CV/job changes
candidate-match-score.listener.tsListens for candidate.created; POSTs to ${AI_SERVICE_URL}/api/v1/matching/candidate best-effort
candidate.service.tsaddTalentAsCandidate, applyAsCandidate, createCandidate emit candidate.created after the Prisma insert

Frontend (Next.js)

FilePurpose
lib/utils/score.tsnormalizeScore() and normalizeScoreNullable() — canonical score normalization (clamp 0-100, round)
lib/hooks/jobs/useRecommendedTalents.tsFetches batch scores via GET_JOB_MATCH_SCORES, filters to 70%+, tracks scoresLoading state
lib/hooks/jobs/useJobCandidates.tsFetches batch scores for pipeline candidates via GET_JOB_MATCH_SCORES
lib/hooks/useUserJobMatchScore.tsPer-job score fetching for job seeker view (individual queries per visible job)
lib/hooks/useRecommendedTalentsSafe.tsNaN-safe fallback for recommended talents (intentionally skips matchScore field)
graphql/operations/job/queries.tsGET_JOB_MATCH_SCORES (batch) and GET_USER_JOB_MATCH_SCORE (individual) queries

Frontend Score Handling

Score Scale

Scores are always stored and transmitted as 0-100 integers (validated by match_score_queries.py). The frontend must never assume a 0-1 fractional scale.

All frontend normalization goes through the shared utility in lib/utils/score.ts:

// Returns 0 for null/undefined — use when a numeric fallback is acceptable
normalizeScore(raw?: number | null): number

// Returns null for null/undefined — use when you need to distinguish "no score" from "0%"
normalizeScoreNullable(raw?: number | null): number | null
Never use fraction-to-percent conversion

A previous bug (n &lt;= 1 ? n * 100 : n) existed in 7 files and incorrectly turned 1% scores into 100%. All score normalization must use normalizeScore() or normalizeScoreNullable() from lib/utils/score.ts. Never add local normalization functions.

Batch Score Fetching

Frontend hooks use the GET_JOB_MATCH_SCORES batch query (one request per jobId) instead of individual GET_USER_JOB_MATCH_SCORE queries per talent. This prevents N+1 query problems when displaying score grids.

query GetJobMatchScores($jobId: String!) {
getJobMatchScores(jobId: $jobId) {
id userId score details createdAt updatedAt
}
}

The Recommended tab on the Job Details page (app/company/jobs/[jobId]/page.tsx) displays talents from the company's talent pool with match scores:

  • Minimum threshold: Only talents with score >= 70% are shown
  • Score loading: A spinner with "Calculating match scores..." displays while batch scores load
  • Score filter: Dropdown only shows relevant bands: "All Scores (70+)", "Advanced (70-80%)", "Expert (81-100%)"
  • Unscored talents: Talents without a pre-computed score (null from backend) are excluded — scores are pre-computed by the background match score generator