From 8d3d5f626fad1d0ff15a210b42c53c005d4f317e Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 5 Oct 2026 06:09:01 -0500 Subject: [PATCH] fix(agent): LinkedIn years-of-experience text boxes get a whole number (#412); 1.66.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkedIn's SDUI Easy Apply dialog draws "How many years of work experience do you have with X?" as a plain 20-character text box. The #332 coercion only fired on type=number or a "numeric" control id, so the drafter's prose ("5+ years", "10+ years — .NET from 2012 …") went in verbatim and the dialog refused it after Next. Seven rows rebuilt on Claude on 2026-10-05 answered every question and still dry-ran 0 mapped. A label that asks "How many years" now takes AnswerDrafter.WholeNumber, and WholeNumber reads "Less than N" as N-1 so "Less than 1 year" is 0, not 1. Version 1.66.5 (csproj, pyproject, __init__, uv.lock, .env.production.example). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXYffreKMhXLNgc2wdhsiw --- .env.production.example | 2 +- BACKLOG.md | 1 + .../GreenhouseEducationTests.cs | 14 ++++++++++++++ api/ApplyTrack.Api/Agent/AnswerDrafter.cs | 12 +++++++++++- .../Agent/Browser/LinkedInEasyApply.cs | 3 +++ api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- pyproject.toml | 2 +- src/applytrack/__init__.py | 2 +- uv.lock | 2 +- 9 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.env.production.example b/.env.production.example index 108ce0a..c616601 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,7 +1,7 @@ # Production deployments intentionally have no working secret defaults. # Copy this file to .env.production, replace every CHANGE-ME value, keep the # result out of version control, and pin a released OSApplyTrack version. -OSAPPLYTRACK_VERSION=1.66.4 +OSAPPLYTRACK_VERSION=1.66.5 POSTGRES_USER=applytrack POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password POSTGRES_DB=applytrack diff --git a/BACKLOG.md b/BACKLOG.md index c56ef13..95de901 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -165,6 +165,7 @@ review items and unsupported forms still need human attention. - [ ] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field) - [ ] [#410 — Agent: a packet's whole answer draft rides one LLM completion under the API's 120 s timeout — Orbis's 29-question form timed out twice and every answer was voided](https://github.com/CryptoJones/OSApplyTrack/issues/410) (not the network or the endpoint — a 36 KB request from the agent's own netns answers in 1.5 s; the real 22-question prompt just takes poolside longer than two minutes. Mitigated on pluto with `Llm__TimeoutSeconds=600` on the agent; the fix is a worker-side ceiling, chunked drafting, or both) - [ ] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (`mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog) +- [x] [#412 — Agent: LinkedIn SDUI years-of-experience text boxes refuse prose answers ("5+ years")](https://github.com/CryptoJones/OSApplyTrack/issues/412) (fixed in 1.66.5: the SDUI dialog draws "How many years …?" as a plain 20-character text box, so the #332 whole-number coercion keyed on `type=number` never fired and the drafter's "10+ years — …" was refused after Next. A label that asks "How many years" now gets the whole number its answer states, and "Less than 1 year" reads 0, not 1. Found on the 2026-10-05 Ready sweep: seven LinkedIn rows rebuilt on Claude answered every question and still dry-ran 0 mapped.) - [x] [#406 — Agent: Ready rows stuck on failures the worker should have recovered from — DNS blip stamped non-transient, browser crash unflagged, LLM 429 hammered every tick](https://github.com/CryptoJones/OSApplyTrack/issues/406) (fixed in 1.66.4: "the posting's host could not be resolved" is a `TransientRunException`, retried once after two seconds and recorded as transient, so the reconciler retries it; a run the browser died under ("Target page, context or browser has been closed") is flagged `transient` by the submitter and read by those words from before; and a pass stops judging at the first `LlmUnavailableException` and holds the tenant off for 15 minutes instead of asking the next eleven leads every five minutes. The eight rows the fix could not reach — four stored `transient: false`, launchdarkly's crash, and three LinkedIn runs from before #403 — were queued again by hand.) - [x] [#403 — Agent: LinkedIn Easy Apply times out on the new SDUI dialog (no .jobs-easy-apply-modal)](https://github.com/CryptoJones/OSApplyTrack/issues/403) (fixed in 1.66.2: the driver now also recognizes LinkedIn's SDUI ``. It finds Next / Review / Submit application and Dismiss by their text or label, reads fields from `label[for]` / `aria-label` and radio options from the text beside each one, catches errors that appear only after a refused Next, unticks Follow by its aria-label, and answers the blank mobile-number box from the packet's standard phone. Checked against the live dialog on 2026-10-03.) diff --git a/api/ApplyTrack.Api.Tests/GreenhouseEducationTests.cs b/api/ApplyTrack.Api.Tests/GreenhouseEducationTests.cs index 539ba9d..f1a5cc4 100644 --- a/api/ApplyTrack.Api.Tests/GreenhouseEducationTests.cs +++ b/api/ApplyTrack.Api.Tests/GreenhouseEducationTests.cs @@ -72,6 +72,11 @@ public void Only_a_stated_finish_year_is_an_end_year(string dates, string? year) [InlineData("3-5", "3")] [InlineData("07", "7")] [InlineData("none", null)] + [InlineData("Less than 1 year", "0")] + [InlineData("less than 3 years", "2")] + [InlineData("under 2", "1")] + [InlineData("0 — I've worked with the OpenAI API directly, not Azure OpenAI.", "0")] + [InlineData("10+ years — .NET application development from 2012 to 2024", "10")] public void A_linkedin_number_box_gets_the_whole_number_an_answer_states(string answer, string? number) => Assert.Equal(number, ApplyTrack.Api.Agent.AnswerDrafter.WholeNumber(answer)); @@ -88,4 +93,13 @@ public void A_degree_in_progress_goes_before_a_completed_certificate() [Fact] public void A_number_too_long_for_an_int_is_still_its_digits() => Assert.Equal("123456789012345678901234567890", ApplyTrack.Api.Agent.AnswerDrafter.WholeNumber("123456789012345678901234567890+")); + + [Theory] + [InlineData("How many years of work experience do you have with C#?", true)] + [InlineData("How many years of work experience do you have with .NET Framework?*", true)] + [InlineData(" how many total years of experience do you have?", true)] + [InlineData("Are you comfortable working in a remote setting?", false)] + [InlineData("Do you have 6+ years of hands-on experience?", false)] + public void A_years_question_wants_a_whole_number_whatever_its_box(string label, bool wants) => + Assert.Equal(wants, ApplyTrack.Api.Agent.AnswerDrafter.WantsWholeNumber(label)); } diff --git a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs index 94c73b5..b996a6a 100644 --- a/api/ApplyTrack.Api/Agent/AnswerDrafter.cs +++ b/api/ApplyTrack.Api/Agent/AnswerDrafter.cs @@ -533,15 +533,25 @@ public static bool IsOpaqueLabel(PacketQuestion q) /// question against (the bank's key is the question, lower-cased). Name fields are left out: /// they are answered from the résumé, not asked. Public for tests. /// The whole number an answer states — "5+" → 5, "10+ years" → 10, "3-5" → 3 (the - /// least it claims) — or null when it states none. For a form's number box (#332). + /// least it claims) — or null when it states none. For a form's number box (#332). + /// "Less than 1 year" is 0, not 1: below N is N-1 (#412). public static string? WholeNumber(string answer) { + var below = System.Text.RegularExpressions.Regex.Match(answer, @"^\s*(?:less|fewer) than\s+(\d{1,9})\b|^\s*(?:under|<)\s*(\d{1,9})\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (below.Success) + return Math.Max(0, int.Parse(below.Groups[1].Success ? below.Groups[1].Value : below.Groups[2].Value) - 1).ToString(System.Globalization.CultureInfo.InvariantCulture); var m = System.Text.RegularExpressions.Regex.Match(answer, @"\d+"); if (!m.Success) return null; var digits = m.Value.TrimStart('0'); return digits.Length == 0 ? "0" : digits; } + /// True when a question's label asks for a count of years ("How many years of work + /// experience do you have with C#?"), whatever control the form draws it with (#412). + public static bool WantsWholeNumber(string label) => + Regex.IsMatch(label, @"^\s*how many (?:total )?years\b", RegexOptions.IgnoreCase); + public static string PastAnswers(IReadOnlyDictionary? pinned) { if (pinned is null || pinned.Count == 0) return "(none yet)"; diff --git a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs index 548921e..7dd5d12 100644 --- a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs +++ b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs @@ -457,7 +457,10 @@ private static async Task FillAsync(IPage page, Field f, string answer) var box = page.Locator($"{Modal} [id=\"{Css(f.ControlId)}\"]").First; // A number box takes a whole number: Insight Global's "How many years … with SQL, // T-SQL, or PL/SQL?" refused the banked "5+" (#332). "5+" is 5, "10+ years" 10. + // The SDUI dialog draws that question as a plain 20-character text box, so the + // label is the only sign it wants a number (#412). if (f.ControlId.Contains("numeric", StringComparison.OrdinalIgnoreCase) + || AnswerDrafter.WantsWholeNumber(f.Label) || await box.GetAttributeAsync("type") == "number") answer = AnswerDrafter.WholeNumber(answer) ?? answer; await box.FillAsync(answer, new() { Timeout = 5_000 }); diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 9913690..4d79c13 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.66.4 + 1.66.5 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml index 49d69e1..846eba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.66.4" +version = "1.66.5" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/src/applytrack/__init__.py b/src/applytrack/__init__.py index 8d6caaa..a344878 100644 --- a/src/applytrack/__init__.py +++ b/src/applytrack/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """applytrack — a local, markdown-backed job-application tracker.""" -__version__ = "1.66.4" +__version__ = "1.66.5" diff --git a/uv.lock b/uv.lock index 1d55baa..5b108f8 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.66.4" +version = "1.66.5" source = { editable = "." } dependencies = [ { name = "cryptography" },