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",