diff --git a/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java b/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java index d5c4928..63e71e1 100644 --- a/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java +++ b/src/main/java/com/jobpilot/extraction/DeterministicRequirementExtractor.java @@ -85,6 +85,24 @@ public class DeterministicRequirementExtractor { + "){0,3}Go" + GO_LIST_TAIL + ")"; + /** + * Explicit Go notation in the job title. A title is short, curated and names the + * stack directly, so two notations that are too weak to trust in prose are unambiguous + * there: a parenthesised language tag ("Juju Software Engineer (Go)") and a slash-separated + * language pair ("Software Engineer, Python / Go", "Software Engineer - Go / Python"). + * Production preview showed {@link #QUALIFIED_GO} alone dropping exactly those rows. + * + *

Deliberately limited to those two forms, and deliberately applied to the title only: + * bare Go in a title is still the English verb ("Ready to Go Platform Engineer"), and the + * body keeps the unchanged conservative rule. The unambiguous spellings — Golang, "Go + * developer", "Go engineer" — already come through {@link #QUALIFIED_GO} wherever they sit. + */ + private static final Pattern TITLE_GO = Pattern.compile("(?i)(?:" + + "\\(\\s*Go\\s*\\)" + + "|(? TECHNOLOGIES = technologies(); private static final List PROGRAMMING = List.of("Java", "TypeScript", "JavaScript", "Python", "Kotlin", "C#", "C++", "Go"); // Whole words only: "internal" or "international" must not count as an internship. @@ -149,17 +167,17 @@ public ExtractedRequirements extract(Job job) { boolean trainee = TRAINEE_SIGNALS.matcher(text).find(); boolean finalYear = Pattern.compile("(?i)(must|only|required|currently)\\s+(?:be\\s+)?(?:a\\s+)?final[- ]year|final[- ]year\\s+(student\\s+)?required") .matcher(text).find(); - List technologies = extractTechnologies(text); + // Seniority reads the title and the body separately, and Go additionally reads the + // title on its own; everything else keeps using the combined text exactly as before. + String titleText = String.valueOf(job.getTitle()).replace('\u00a0', ' '); + String bodyText = String.valueOf(job.getDescription()).replace('\u00a0', ' '); + List technologies = extractTechnologies(text, titleText); List programming = technologies.stream().filter(PROGRAMMING::contains).toList(); List spoken = spokenLanguages(text); List mentoring = signals(lower, Map.of( "mentorship", "mentorship", "mentor", "mentor", "structured learning", "structured learning", "structured mentorship", "structured mentorship", "training program", "training program", "academy", "academy", "pair programming", "pair programming")); - // Seniority reads the title and the body separately; everything else keeps using - // the combined text exactly as before. - String titleText = String.valueOf(job.getTitle()).replace('\u00a0', ' '); - String bodyText = String.valueOf(job.getDescription()).replace('\u00a0', ' '); return new ExtractedRequirements(seniority(titleText, bodyText), trainee, experience(text), education(text), finalYear, technologies, programming, spoken, job.getLocation(), remoteEligibility(lower), mentoring, workAuthorization(text), @@ -202,8 +220,15 @@ private static String token(String tech) { }; } - private List extractTechnologies(String text) { - return TECHNOLOGIES.entrySet().stream().filter(e -> e.getValue().matcher(text).find()) + /** + * Combined title+description evidence, plus the title-only Go recovery. The scan still walks + * {@link #TECHNOLOGIES} in declaration order, so ordering stays stable and each term — Go + * included, however many rules matched it — is emitted at most once. + */ + private List extractTechnologies(String text, String title) { + return TECHNOLOGIES.entrySet().stream() + .filter(e -> e.getValue().matcher(text).find() + || ("Go".equals(e.getKey()) && TITLE_GO.matcher(title).find())) .map(Map.Entry::getKey).toList(); } diff --git a/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java b/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java index 55b877c..42d6221 100644 --- a/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java +++ b/src/test/java/com/jobpilot/extraction/DeterministicRequirementExtractorTest.java @@ -290,6 +290,11 @@ private List technologiesOf(String description) { .technologies(); } + private List technologiesOfTitle(String title) { + return extractor.extract(jobWithDescription(title, "Build and ship backend services.")) + .technologies(); + } + @ParameterizedTest @ValueSource(strings = { "We build REST API services", @@ -444,6 +449,82 @@ void rejectsOrdinaryEnglishGo(String description) { assertThat(technologiesOf(description)).doesNotContain("Go"); } + // ------------------------------------------------- explicit Go notation in the job title + + /** Production preview rows where the conservative body rule dropped a title-declared Go. */ + @ParameterizedTest + @ValueSource(strings = { + "Juju Software Engineer (Go)", + "Distributed Systems Testing Software Engineer, Python / Go", + "Software Engineer - Go / Python", + "Software Engineer (Go), Platform", + "Backend Engineer ( Go )", + "Senior Engineer, Go/Rust", + "Backend Engineer - TypeScript / Go" + }) + void extractsExplicitGoNotationFromTheTitle(String title) { + assertThat(technologiesOfTitle(title)).contains("Go"); + } + + @Test + void titleDeclaredGoIsAlsoAProgrammingLanguageAndListedOnce() { + var requirements = extractor.extract(jobWithDescription("Juju Software Engineer (Go)", + "Work on SQL storage, Docker images and Kubernetes operators.")); + + assertThat(requirements.technologies()) + .containsExactly("SQL", "Docker", "Kubernetes", "Go"); + assertThat(requirements.programmingLanguages()).containsExactly("Go"); + } + + /** Both a title and a body rule can fire for one posting; Go must still appear once. */ + @Test + void goIsNotDuplicatedWhenTitleAndBodyEvidenceBothMatch() { + var requirements = extractor.extract(jobWithDescription("Software Engineer - Go / Python", + "Golang experience preferred; our services are written in Go, mostly.")); + + assertThat(requirements.technologies()).containsOnlyOnce("Go"); + assertThat(requirements.programmingLanguages()).containsOnlyOnce("Go"); + } + + /** The unambiguous title spellings already handled by the body rule keep working. */ + @ParameterizedTest + @ValueSource(strings = { + "Golang Software Engineer, Commercial Systems", + "Go (Golang) Software Engineer, Developer Tooling and Containers", + "Software Engineer - Python/Golang - Kubernetes", + "Go Developer", + "Go Engineer, Payments" + }) + void keepsUnambiguousGoTitlesWorking(String title) { + assertThat(technologiesOfTitle(title)).contains("Go"); + } + + /** Ordinary English "go" in a title is still the verb, not the language. */ + @ParameterizedTest + @ValueSource(strings = { + "Ready to Go Platform Engineer", + "Go To Market Engineer", + "Engineer, Go-Live Support", + "Software Engineer, Ready to Go Team" + }) + void rejectsOrdinaryEnglishGoInTheTitle(String title) { + assertThat(technologiesOfTitle(title)).doesNotContain("Go"); + } + + /** + * The recovery is title evidence only. A parenthesised or slashed Go buried in prose stays + * unqualified, so the body rule keeps the semantics production is already running. + */ + @ParameterizedTest + @ValueSource(strings = { + "The toolchain team ships (Go) tooling for internal customers", + "Our internal wiki lists Python / Go under legacy owners", + "Teams may pick Go / Rust for greenfield work" + }) + void doesNotTurnBodyOnlyGoNotationIntoANewSignal(String description) { + assertThat(technologiesOf(description)).doesNotContain("Go"); + } + @ParameterizedTest @ValueSource(strings = { "Experience with Spring Boot 3",