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));
+ }
+}