From 725e46b5f78eea312ad9078d734a4857d5ffae3a Mon Sep 17 00:00:00 2001 From: Pavlo Sushkov Date: Wed, 12 Aug 2026 16:53:36 +0300 Subject: [PATCH] refactor: make candidate explicit in matching --- .../matching/CandidateMatchingProfile.java | 70 +++++++++++ .../jobpilot/matching/JobMatchingService.java | 33 ++--- .../CandidateMatchingProfileTest.java | 114 ++++++++++++++++++ 3 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/jobpilot/matching/CandidateMatchingProfile.java create mode 100644 src/test/java/com/jobpilot/matching/CandidateMatchingProfileTest.java diff --git a/src/main/java/com/jobpilot/matching/CandidateMatchingProfile.java b/src/main/java/com/jobpilot/matching/CandidateMatchingProfile.java new file mode 100644 index 0000000..193edcb --- /dev/null +++ b/src/main/java/com/jobpilot/matching/CandidateMatchingProfile.java @@ -0,0 +1,70 @@ +package com.jobpilot.matching; + +import com.jobpilot.config.JobPilotProperties; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable candidate snapshot consumed by the deterministic scorer. + * + *

It is deliberately not the persistent {@code com.jobpilot.candidate.CandidateProfile}, not a + * JPA entity, and not an account: it carries only the candidate values the current scoring + * algorithm reads, so that scoring becomes an explicit function of a candidate rather than of one + * globally configured one. Scoring must never fetch it from a repository. + * + *

Both skill sets are normalized to lowercase and deduplicated at construction. The scorer + * matches lowercased requirement technologies against them and divides by {@code + * backendSkills.size()}, so normalization belongs to the value rather than to the scorer. + */ +public record CandidateMatchingProfile( + String homeCountry, + Set backendSkills, + Set supportingSkills, + boolean currentStudent, + boolean finalYearStudent, + double commercialJavaYears) { + + public CandidateMatchingProfile { + homeCountry = requireText(homeCountry, "Candidate home country"); + backendSkills = normalize(backendSkills, "Candidate backend skills"); + supportingSkills = normalize(supportingSkills, "Candidate supporting skills"); + if (!Double.isFinite(commercialJavaYears) || commercialJavaYears < 0) { + throw new IllegalArgumentException( + "Candidate commercial Java years must be a finite, non-negative number"); + } + } + + /** Compatibility bridge from the single configured candidate in application.yml. */ + public static CandidateMatchingProfile fromLegacy(JobPilotProperties.Candidate candidate) { + Objects.requireNonNull(candidate, "Legacy candidate configuration is required"); + return new CandidateMatchingProfile(candidate.homeCountry(), + asSet(candidate.backendSkills(), "Candidate backend skills"), + asSet(candidate.supportingSkills(), "Candidate supporting skills"), + candidate.currentStudent(), candidate.finalYearStudent(), + candidate.commercialJavaYears()); + } + + /** Configured lists may repeat an entry; deduplication belongs to normalization, not binding. */ + private static Set asSet(Collection values, String name) { + Objects.requireNonNull(values, name + " are required"); + return new LinkedHashSet<>(values); + } + + private static Set normalize(Collection values, String name) { + Objects.requireNonNull(values, name + " are required"); + Set normalized = new LinkedHashSet<>(); + for (String value : values) { + normalized.add(Objects.requireNonNull(value, name + " must not contain null entries") + .toLowerCase(Locale.ROOT)); + } + return Set.copyOf(normalized); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value; + } +} diff --git a/src/main/java/com/jobpilot/matching/JobMatchingService.java b/src/main/java/com/jobpilot/matching/JobMatchingService.java index 05d6173..63f8d7c 100644 --- a/src/main/java/com/jobpilot/matching/JobMatchingService.java +++ b/src/main/java/com/jobpilot/matching/JobMatchingService.java @@ -12,24 +12,33 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Set; import org.springframework.stereotype.Service; @Service public class JobMatchingService { private final Clock clock; - private final JobPilotProperties.Candidate candidate; - private final Set backendSkills; - private final Set supportingSkills; + private final CandidateMatchingProfile defaultCandidate; public JobMatchingService(Clock clock, JobPilotProperties properties) { this.clock = clock; - this.candidate = properties.candidate(); - this.backendSkills = lowerSet(candidate.backendSkills()); - this.supportingSkills = lowerSet(candidate.supportingSkills()); + this.defaultCandidate = CandidateMatchingProfile.fromLegacy(properties.candidate()); } + /** Production entry point: scores against the single configured candidate. */ public ScoreCard score(Job job, ExtractedRequirements r) { + return score(job, r, defaultCandidate); + } + + /** + * Deterministic scoring against an explicit candidate. The candidate is an input, never + * state fetched during calculation, so the same job can be scored for several candidates. + */ + public ScoreCard score(Job job, ExtractedRequirements r, CandidateMatchingProfile candidate) { + Objects.requireNonNull(candidate, "Candidate matching profile is required"); + Set backendSkills = candidate.backendSkills(); + Set supportingSkills = candidate.supportingSkills(); String text = (job.getTitle() + " " + job.getDescription() + " " + job.getLocation()).toLowerCase(Locale.ROOT); List strengths = new ArrayList<>(); List risks = new ArrayList<>(); @@ -41,7 +50,7 @@ public ScoreCard score(Job job, ExtractedRequirements r) { if (has(text, "computer science", "informatics", "software", "technical degree", "related field")) formal += 5; else if (r.requiredEducation() == null) formal += 4; if (!r.finalYearMandatory() || candidate.finalYearStudent()) formal += 6; - if (romaniaEligible(job, r)) formal += 6; + if (romaniaEligible(job, r, candidate)) formal += 6; if (formal >= 19) strengths.add("Formal eligibility is compatible with a current student in Romania"); int backendMatches = (int) r.technologies().stream().map(String::toLowerCase).filter(backendSkills::contains).distinct().count(); @@ -63,7 +72,7 @@ public ScoreCard score(Job job, ExtractedRequirements r) { int location = locationScore(job, r); if (location >= 8) strengths.add("Location and work format suit Bucharest or Romania"); - int experience = experienceScore(r.requiredExperienceYears(), r.internshipOrTrainee()); + int experience = experienceScore(r.requiredExperienceYears(), r.internshipOrTrainee(), candidate); if (experience >= 8) strengths.add("Commercial experience expectations are entry-level compatible"); int freshness = freshness(job); @@ -124,7 +133,7 @@ else if (years != null && years >= 2) { experience, freshness, penalties, List.copyOf(strengths), List.copyOf(risks), List.copyOf(blockers)); } - private int experienceScore(Double years, boolean trainee) { + private int experienceScore(Double years, boolean trainee, CandidateMatchingProfile candidate) { if (years == null) return trainee || candidate.commercialJavaYears() == 0 ? 10 : 7; if (years <= 0) return 10; if (years <= 1) return 8; @@ -157,7 +166,7 @@ private int locationScore(Job job, ExtractedRequirements r) { return 2; } - private boolean romaniaEligible(Job job, ExtractedRequirements r) { + private boolean romaniaEligible(Job job, ExtractedRequirements r, CandidateMatchingProfile candidate) { if (job.getLocationEligibility() == LocationEligibility.BUCHAREST_LOCAL || job.getLocationEligibility() == LocationEligibility.REMOTE_ROMANIA_ELIGIBLE) return true; String remote = String.valueOf(r.remoteEligibility()).toLowerCase(Locale.ROOT); @@ -177,8 +186,4 @@ private boolean has(String text, String... values) { for (String value : values) if (text.contains(value)) return true; return false; } - - private Set lowerSet(List values) { - return values.stream().map(value -> value.toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toUnmodifiableSet()); - } } diff --git a/src/test/java/com/jobpilot/matching/CandidateMatchingProfileTest.java b/src/test/java/com/jobpilot/matching/CandidateMatchingProfileTest.java new file mode 100644 index 0000000..bfbf898 --- /dev/null +++ b/src/test/java/com/jobpilot/matching/CandidateMatchingProfileTest.java @@ -0,0 +1,114 @@ +package com.jobpilot.matching; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.jobpilot.common.UrlCanonicalizer; +import com.jobpilot.config.JobPilotProperties; +import com.jobpilot.extraction.DeterministicRequirementExtractor; +import com.jobpilot.jobs.domain.ExtractedRequirements; +import com.jobpilot.jobs.domain.Job; +import com.jobpilot.jobs.domain.RawJob; +import com.jobpilot.jobs.service.JobNormalizer; +import com.jobpilot.support.TestProperties; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CandidateMatchingProfileTest { + private final Clock clock = Clock.fixed(Instant.parse("2026-07-17T12:00:00Z"), ZoneOffset.UTC); + private final JobNormalizer normalizer = new JobNormalizer(clock, new UrlCanonicalizer()); + private final DeterministicRequirementExtractor extractor = new DeterministicRequirementExtractor(); + private final JobMatchingService matching = new JobMatchingService(clock, TestProperties.create()); + + @Test + void legacyDefaultAndEquivalentExplicitProfileScoreIdentically() throws Exception { + Job job = job("Java Developer Intern", "strong-java-internship.txt", "Bucharest, Romania"); + ExtractedRequirements requirements = extractor.extract(job); + + CandidateMatchingProfile explicit = new CandidateMatchingProfile("Romania", + Set.of("Java", "Spring Boot", "REST", "SQL", "PostgreSQL", "JPA", "Maven", "JUnit"), + Set.of("React", "TypeScript", "JavaScript", "HTML", "CSS", "Git", "CI/CD", + "GitHub Actions"), + true, false, 0); + + assertThat(matching.score(job, requirements, explicit)) + .isEqualTo(matching.score(job, requirements)); + } + + @Test + void sameVacancyScoresDifferentlyForDifferentCandidates() throws Exception { + Job job = job("Java Developer Intern", "strong-java-internship.txt", "Bucharest, Romania"); + ExtractedRequirements requirements = extractor.extract(job); + + CandidateMatchingProfile javaCandidate = new CandidateMatchingProfile("Romania", + Set.of("Java", "Spring Boot", "REST", "SQL", "PostgreSQL"), + Set.of("Git", "CI/CD"), true, false, 0); + CandidateMatchingProfile dataCandidate = new CandidateMatchingProfile("Romania", + Set.of("Python", "Django", "Pandas", "NumPy", "Airflow"), + Set.of("Git", "CI/CD"), true, false, 0); + + ScoreCard javaScore = matching.score(job, requirements, javaCandidate); + ScoreCard dataScore = matching.score(job, requirements, dataCandidate); + + assertThat(javaScore.javaBackend()).isPositive(); + assertThat(dataScore.javaBackend()).isZero(); + assertThat(javaScore.score()).isGreaterThan(dataScore.score()); + assertThat(dataScore.risks()).anyMatch(value -> value.contains("Java/backend")); + } + + @Test + void convertsLegacyCandidateConfigurationWithoutLosingValues() { + JobPilotProperties.Candidate legacy = TestProperties.create().candidate(); + + CandidateMatchingProfile profile = CandidateMatchingProfile.fromLegacy(legacy); + + assertThat(profile.homeCountry()).isEqualTo("Romania"); + assertThat(profile.backendSkills()).containsExactlyInAnyOrder("java", "spring boot", + "rest", "sql", "postgresql", "jpa", "maven", "junit"); + assertThat(profile.supportingSkills()).contains("react", "github actions"); + assertThat(profile.currentStudent()).isTrue(); + assertThat(profile.finalYearStudent()).isFalse(); + assertThat(profile.commercialJavaYears()).isZero(); + } + + @Test + void copiesSkillCollectionsDefensively() { + Set backendSkills = new LinkedHashSet<>(Set.of("Java")); + + CandidateMatchingProfile profile = new CandidateMatchingProfile("Romania", + backendSkills, Set.of("Git"), true, false, 0); + backendSkills.add("Python"); + + assertThat(profile.backendSkills()).containsExactly("java"); + assertThatThrownBy(() -> profile.backendSkills().add("python")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsMissingCandidateValues() { + assertThatThrownBy(() -> new CandidateMatchingProfile(null, Set.of("Java"), Set.of(), true, false, 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CandidateMatchingProfile("Romania", null, Set.of(), true, false, 0)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new CandidateMatchingProfile("Romania", Set.of("Java"), null, true, false, 0)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new CandidateMatchingProfile("Romania", Set.of("Java"), Set.of(), true, false, -1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CandidateMatchingProfile.fromLegacy(null)) + .isInstanceOf(NullPointerException.class); + } + + private Job job(String title, String fixture, String location) throws Exception { + String text; + try (var stream = getClass().getResourceAsStream("/fixtures/" + fixture)) { + text = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + return normalizer.normalize(new RawJob("fixture", fixture, "https://example.com/" + fixture, + title, "Example", location, text, "Internship", clock.instant(), null, text)); + } +}