Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/main/java/com/jobpilot/matching/CandidateMatchingProfile.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<String> backendSkills,
Set<String> 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<String> asSet(Collection<String> values, String name) {
Objects.requireNonNull(values, name + " are required");
return new LinkedHashSet<>(values);
}

private static Set<String> normalize(Collection<String> values, String name) {
Objects.requireNonNull(values, name + " are required");
Set<String> 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;
}
}
33 changes: 19 additions & 14 deletions src/main/java/com/jobpilot/matching/JobMatchingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> backendSkills;
private final Set<String> 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<String> backendSkills = candidate.backendSkills();
Set<String> supportingSkills = candidate.supportingSkills();
String text = (job.getTitle() + " " + job.getDescription() + " " + job.getLocation()).toLowerCase(Locale.ROOT);
List<String> strengths = new ArrayList<>();
List<String> risks = new ArrayList<>();
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<String> lowerSet(List<String> values) {
return values.stream().map(value -> value.toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toUnmodifiableSet());
}
}
114 changes: 114 additions & 0 deletions src/test/java/com/jobpilot/matching/CandidateMatchingProfileTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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));
}
}
Loading