From bc3ce69a9168266bc4326eefcdeb7c5ac9a2a60a Mon Sep 17 00:00:00 2001 From: Alan Date: Fri, 9 Oct 2026 10:18:41 -0700 Subject: [PATCH 1/2] feat(career-jobs): widen an empty search (drop seniority words, then statewide) and say what was widened --- .../Career/CareerJobObservationApiTests.cs | 60 ++++++++++++++- .../Career/JobObservationServiceTests.cs | 9 ++- .../Career/JobObservationContracts.cs | 24 +++++- .../Services/Career/JobObservationService.cs | 75 ++++++++++++++----- .../pages/career/career-jobs.component.html | 3 + .../app/pages/career/career-jobs.component.ts | 10 +++ .../app/services/career-profile.service.ts | 4 + .../tests/career-job-observations.spec.ts | 16 ++++ .../tests/fixtures/career-job-observations.ts | 22 ++++-- 9 files changed, 193 insertions(+), 30 deletions(-) diff --git a/AI.ProfilePhotoMaker.API.Tests/Integration/Career/CareerJobObservationApiTests.cs b/AI.ProfilePhotoMaker.API.Tests/Integration/Career/CareerJobObservationApiTests.cs index 9aeb7c18..6514f666 100644 --- a/AI.ProfilePhotoMaker.API.Tests/Integration/Career/CareerJobObservationApiTests.cs +++ b/AI.ProfilePhotoMaker.API.Tests/Integration/Career/CareerJobObservationApiTests.cs @@ -39,6 +39,8 @@ public CareerJobObservationApiTests(CareerJobFactory factory) factory.Source.LastQuery = null; factory.Source.ProviderTotal = null; factory.Source.IsConfigured = true; + factory.Source.Queries.Clear(); + factory.Source.Answers = null; } private static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); @@ -47,12 +49,12 @@ private static RawJobObservation Job(string id, string city = "Denver", string s new(id, $"IT Specialist {id}", "Department of Veterans Affairs", new[] { new RawJobLocation(city, state) }, 98500, 128000, "usd_per_year", "annual", Today.AddDays(-2), Today.AddDays(10), remote, null, "2210", "GS-12", $"https://www.usajobs.gov/job/{id}"); - private async Task UserAsync(bool occupation = true, bool preferDenver = false) + private async Task UserAsync(bool occupation = true, bool preferDenver = false, string role = "Software developer") { var user = new CareerClient(_factory); await CareerClient.ReadDataAsync(await user.PostGoalAsync(new { - targetRole = "Software developer", targetLocation = "Denver, CO", workArrangement = "hybrid", + targetRole = role, targetLocation = "Denver, CO", workArrangement = "hybrid", desiredPayMin = 120000, desiredPayMax = 160000, weeklyEffortHours = 5, confirmed = true }), 201); using var scope = _factory.Services.CreateScope(); @@ -142,6 +144,60 @@ public async Task NoOccupationSearchesByTheTargetRoleAndSaysSo() _factory.Source.LastQuery.OccupationTitle.Should().Be("Software developer"); } + [Fact] + public async Task NoMatchesRetriesWithoutSeniorityWordsAndSaysSo() + { + _factory.Source.Raw.Add(Job("1")); + _factory.Source.Answers = q => q.OccupationTitle == "data analyst"; + + var data = await Get(await UserAsync(occupation: false, role: "Senior Data Analyst")); + + _factory.Source.Queries.Select(q => q.OccupationTitle).Should().Equal("Senior Data Analyst", "data analyst"); + data.GetProperty("observations").GetArrayLength().Should().Be(1); + var search = data.GetProperty("search"); + search.GetProperty("keyword").GetString().Should().Be("data analyst"); + search.GetProperty("broadened").EnumerateArray().Select(e => e.GetString()).Should().Equal("keyword"); + } + + [Fact] + public async Task StillNoMatchesRetriesStatewideAndSaysSo() + { + _factory.Source.Raw.Add(Job("1", city: "Fort Collins")); + _factory.Source.Answers = q => q.AreaResolution == "state"; + + var data = await Get(await UserAsync(occupation: false, role: "Senior Data Analyst")); + + _factory.Source.Queries.Should().HaveCount(3); + var last = _factory.Source.Queries[^1]; + (last.OccupationTitle, last.AreaResolution).Should().Be(("data analyst", "state")); + data.GetProperty("observations").GetArrayLength().Should().Be(1); + var search = data.GetProperty("search"); + search.GetProperty("areaTitle").GetString().Should().Be(last.AreaTitle); + search.GetProperty("broadened").EnumerateArray().Select(e => e.GetString()).Should().Equal("keyword", "area"); + } + + [Fact] + public async Task MatchesOnTheFirstSearchDoNotBroaden() + { + _factory.Source.Raw.Add(Job("1")); + + var data = await Get(await UserAsync()); + + _factory.Source.Queries.Should().HaveCount(1); + data.GetProperty("search").GetProperty("broadened").GetArrayLength().Should().Be(0); + } + + [Fact] + public async Task AUserTextFilterIsNeverBroadened() + { + _factory.Source.Raw.Add(Job("1")); + _factory.Source.Answers = _ => false; + + await Get(await UserAsync(occupation: false, role: "Senior Data Analyst"), "?q=nurse"); + + _factory.Source.Queries.Should().HaveCount(1); + } + [Fact] public async Task AConfirmedOccupationSearchesByTheOccupation() { diff --git a/AI.ProfilePhotoMaker.API.Tests/Services/Career/JobObservationServiceTests.cs b/AI.ProfilePhotoMaker.API.Tests/Services/Career/JobObservationServiceTests.cs index 269b6c63..e8a5b0ad 100644 --- a/AI.ProfilePhotoMaker.API.Tests/Services/Career/JobObservationServiceTests.cs +++ b/AI.ProfilePhotoMaker.API.Tests/Services/Career/JobObservationServiceTests.cs @@ -18,15 +18,22 @@ public sealed class FakeJobObservationSource : IJobObservationSource public Exception? Failure { get; set; } public JobObservationQuery? LastQuery { get; set; } public int? ProviderTotal { get; set; } + /// Every query sent, in order. + public List Queries { get; } = new(); + /// When set, only queries it accepts get ; others get an empty page. + public Func? Answers { get; set; } public Task FetchAsync(JobObservationQuery query, CancellationToken ct) { LastQuery = query; + Queries.Add(query); if (Failure != null) { throw Failure; } - return Task.FromResult(new JobSourcePage(Raw.ToList(), ProviderTotal)); + return Task.FromResult(Answers == null || Answers(query) + ? new JobSourcePage(Raw.ToList(), ProviderTotal) + : new JobSourcePage(new List(), 0)); } } diff --git a/AI.ProfilePhotoMaker.API/Services/Career/JobObservationContracts.cs b/AI.ProfilePhotoMaker.API/Services/Career/JobObservationContracts.cs index bc837ec0..aa02a236 100644 --- a/AI.ProfilePhotoMaker.API/Services/Career/JobObservationContracts.cs +++ b/AI.ProfilePhotoMaker.API/Services/Career/JobObservationContracts.cs @@ -91,7 +91,29 @@ public sealed record JobObservationResult( JobSearchDto? Search = null); /// What the source was searched with: the confirmed occupation, or the goal's target role until one is confirmed. -public sealed record JobSearchDto(string Basis, string Keyword); +public sealed record JobSearchDto(string Basis, string Keyword, string? AreaTitle = null, IReadOnlyList? Broadened = null); + +/// Which parts of the search were widened after the first one found nothing. +public static class JobSearchBroadening +{ + public const string Keyword = "keyword"; + public const string Area = "area"; + + private static readonly HashSet Seniority = new(StringComparer.OrdinalIgnoreCase) + { + "senior", "sr", "sr.", "junior", "jr", "jr.", "lead", "principal", "staff", "chief", "head", + "associate", "entry-level", "entry", "level", "i", "ii", "iii", "iv", "v" + }; + + /// The keyword without seniority words, lower-cased; null when nothing would change or nothing is left. + public static string? Simplify(string keyword) + { + var words = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var kept = words.Where(w => !Seniority.Contains(w.Trim(',', '-'))).ToList(); + if (kept.Count == 0 || kept.Count == words.Length) return null; + return string.Join(' ', kept).ToLowerInvariant(); + } +} public static class JobSearchBasis { diff --git a/AI.ProfilePhotoMaker.API/Services/Career/JobObservationService.cs b/AI.ProfilePhotoMaker.API/Services/Career/JobObservationService.cs index 63a7fdbc..11f4b28e 100644 --- a/AI.ProfilePhotoMaker.API/Services/Career/JobObservationService.cs +++ b/AI.ProfilePhotoMaker.API/Services/Career/JobObservationService.cs @@ -107,29 +107,64 @@ public async Task> GetObservationsAsync( } else { - var startedAt = System.Diagnostics.Stopwatch.GetTimestamp(); - var outcome = Models.Career.CareerUsageOutcomes.Ok; - try + var userArea = resolved.Local == null ? null : areas.FirstOrDefault(a => a.Code == resolved.Local.Code); + // Attempts, in order: as asked; without seniority words; then statewide. A user's own text filter + // is never widened. At most three source calls, each with its own ledger row. + var attempts = new List<(JobObservationQuery Query, MarketArea? Area, string[] Broadened)> { (query, userArea, Array.Empty()) }; + if (search != null && string.IsNullOrWhiteSpace(q)) { - var page = await _source.FetchAsync(query, ct); - var userArea = resolved.Local == null ? null : areas.FirstOrDefault(a => a.Code == resolved.Local.Code); - normalized = JobObservationNormalizer.Normalize(page.Items, page.ProviderTotal, query, info, now, areas, userArea); - } - catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) - { - // Any mapping or provider failure is the honest unavailable state, never a 500. - _logger.LogWarning(ex, "Job source {Source} is unavailable", info.SourceId); - reason = JobSourceReasons.Unavailable; - normalized = JobObservationNormalizer.Empty; - outcome = Models.Career.CareerUsageOutcomes.Failed; + var simpler = JobSearchBroadening.Simplify(search.Keyword); + var current = query; + var steps = new List(); + if (simpler != null) + { + current = current with { OccupationTitle = simpler }; + steps.Add(JobSearchBroadening.Keyword); + attempts.Add((current, userArea, steps.ToArray())); + } + var stateArea = userArea?.Type == "metro" + ? areas.FirstOrDefault(a => a.Type == "state" && a.State.Equals(userArea.State, StringComparison.OrdinalIgnoreCase)) + : null; + if (stateArea != null) + { + steps.Add(JobSearchBroadening.Area); + attempts.Add((current with { AreaCode = stateArea.Code, AreaTitle = stateArea.Title, AreaResolution = MarketResolutions.State }, + stateArea, steps.ToArray())); + } } - finally + + foreach (var (attemptQuery, attemptArea, broadened) in attempts) { - // One ledger row per external source call; counts and timing only. - _db.CareerUsageEvents.Add(CareerUsage.Event( - ownerId, null, Models.Career.CareerUsageActions.JobSource, outcome, now.UtcDateTime, - (int)System.Diagnostics.Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds)); - try { await _db.SaveChangesAsync(CancellationToken.None); } catch (DbUpdateException) { _db.ChangeTracker.Clear(); } + var startedAt = System.Diagnostics.Stopwatch.GetTimestamp(); + var outcome = Models.Career.CareerUsageOutcomes.Ok; + try + { + var page = await _source.FetchAsync(attemptQuery, ct); + normalized = JobObservationNormalizer.Normalize(page.Items, page.ProviderTotal, attemptQuery, info, now, areas, attemptArea); + search = search == null ? null : search with + { + Keyword = attemptQuery.OccupationTitle ?? search.Keyword, + AreaTitle = attemptQuery.AreaTitle, + Broadened = broadened + }; + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + // Any mapping or provider failure is the honest unavailable state, never a 500. + _logger.LogWarning(ex, "Job source {Source} is unavailable", info.SourceId); + reason = JobSourceReasons.Unavailable; + normalized = JobObservationNormalizer.Empty; + outcome = Models.Career.CareerUsageOutcomes.Failed; + } + finally + { + // One ledger row per external source call; counts and timing only. + _db.CareerUsageEvents.Add(CareerUsage.Event( + ownerId, null, Models.Career.CareerUsageActions.JobSource, outcome, now.UtcDateTime, + (int)System.Diagnostics.Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds)); + try { await _db.SaveChangesAsync(CancellationToken.None); } catch (DbUpdateException) { _db.ChangeTracker.Clear(); } + } + if (reason != null || normalized.Counts.Shown > 0) break; } } diff --git a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.html b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.html index caaa19b6..2f90708b 100644 --- a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.html +++ b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.html @@ -28,6 +28,9 @@

Open postings

Confirm your occupation } + @if (broadenedNote(); as broadened) { +

{{ broadened }}

+ } @if (error()) { } diff --git a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts index 934610b4..4555de6c 100644 --- a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts +++ b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts @@ -68,6 +68,16 @@ export class CareerJobsComponent implements OnInit { q = signal(''); coverage = computed(() => this.result()?.coverage ?? null); + /** Plain-language note when the first search found nothing and the API widened it. */ + broadenedNote = computed(() => { + const search = this.result()?.search; + const steps = search?.broadened ?? []; + if (!search || steps.length === 0) return null; + const parts: string[] = []; + if (steps.includes('keyword')) parts.push(`searched for “${search.keyword}” instead`); + if (steps.includes('area') && search.areaTitle) parts.push(`across ${search.areaTitle}`); + return `Nothing matched the first search, so we ${parts.join(' ')}.`; + }); /** Set when no occupation is confirmed and the source was searched by the goal's target role. */ roleSearch = computed(() => { const search = this.result()?.search; diff --git a/AI.ProfilePhotoMaker.UI/src/app/services/career-profile.service.ts b/AI.ProfilePhotoMaker.UI/src/app/services/career-profile.service.ts index 2482f1d8..b34de38d 100644 --- a/AI.ProfilePhotoMaker.UI/src/app/services/career-profile.service.ts +++ b/AI.ProfilePhotoMaker.UI/src/app/services/career-profile.service.ts @@ -473,6 +473,10 @@ export interface JobSearch { /** What the source was searched with: the confirmed occupation, or the goal's target role until then. */ basis: 'occupation' | 'target_role'; keyword: string; + /** Area searched last; differs from the page area when the search went statewide. */ + areaTitle?: string | null; + /** What was widened after the first search found nothing. */ + broadened?: ('keyword' | 'area')[] | null; } export interface JobObservations { occupation: { code: string; title: string } | null; diff --git a/AI.ProfilePhotoMaker.UI/tests/career-job-observations.spec.ts b/AI.ProfilePhotoMaker.UI/tests/career-job-observations.spec.ts index ec058327..3213f2fe 100644 --- a/AI.ProfilePhotoMaker.UI/tests/career-job-observations.spec.ts +++ b/AI.ProfilePhotoMaker.UI/tests/career-job-observations.spec.ts @@ -220,3 +220,19 @@ test('without a confirmed occupation, postings are searched by the target role a '/app/career/occupation' ); }); + +test('a widened search says what was widened', async ({ page }) => { + await mock(page, { broadened: { keyword: 'data analyst', areaTitle: 'Colorado' } }); + await open(page); + await expect(page.locator('[data-observation]').first()).toBeVisible(); + const note = page.locator('[data-search-broadened]'); + await expect(note).toContainText('“data analyst”'); + await expect(note).toContainText('Colorado'); +}); + +test('an unwidened search shows no widening note', async ({ page }) => { + await mock(page); + await open(page); + await expect(page.locator('[data-observation]').first()).toBeVisible(); + await expect(page.locator('[data-search-broadened]')).toHaveCount(0); +}); diff --git a/AI.ProfilePhotoMaker.UI/tests/fixtures/career-job-observations.ts b/AI.ProfilePhotoMaker.UI/tests/fixtures/career-job-observations.ts index 10906a7b..7d05ae4d 100644 --- a/AI.ProfilePhotoMaker.UI/tests/fixtures/career-job-observations.ts +++ b/AI.ProfilePhotoMaker.UI/tests/fixtures/career-job-observations.ts @@ -46,6 +46,8 @@ export interface Options { reason?: 'source_not_configured' | 'source_unavailable' | 'occupation_required'; /** No confirmed occupation: the API searched by the goal's target role. */ byTargetRole?: string; + /** The API widened the search: keyword without seniority words, then statewide. */ + broadened?: { keyword: string; areaTitle?: string }; stale?: boolean; titles?: string[]; } @@ -60,14 +62,22 @@ export function payload(url: URL, options: Options): JobObservations { if (eligibleOnly) list = list.filter(o => o.remoteEligibility === 'eligible'); const available = !options.reason; const shown = available ? list : []; - const noOccupation = !!options.byTargetRole || options.reason === 'occupation_required'; + const noOccupation = + !!options.byTargetRole || !!options.broadened || options.reason === 'occupation_required'; return { occupation: noOccupation ? null : { code: '15-1252.00', title: 'Software Developers' }, - search: options.byTargetRole - ? { basis: 'target_role', keyword: options.byTargetRole } - : noOccupation - ? null - : { basis: 'occupation', keyword: 'Software Developers' }, + search: options.broadened + ? { + basis: 'target_role', + keyword: options.broadened.keyword, + areaTitle: options.broadened.areaTitle ?? 'Denver-Aurora-Centennial, CO', + broadened: options.broadened.areaTitle ? ['keyword', 'area'] : ['keyword'], + } + : options.byTargetRole + ? { basis: 'target_role', keyword: options.byTargetRole } + : noOccupation + ? null + : { basis: 'occupation', keyword: 'Software Developers' }, area: { input: url.searchParams.get('area') ?? 'Denver, CO', resolution: 'metro', From 3720bff83ec058fd81284946e1638ecd3506283a Mon Sep 17 00:00:00 2001 From: Alan Date: Fri, 9 Oct 2026 10:29:27 -0700 Subject: [PATCH 2/2] style: curly braces in broadenedNote --- .../src/app/pages/career/career-jobs.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts index 4555de6c..cb15ced5 100644 --- a/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts +++ b/AI.ProfilePhotoMaker.UI/src/app/pages/career/career-jobs.component.ts @@ -72,10 +72,10 @@ export class CareerJobsComponent implements OnInit { broadenedNote = computed(() => { const search = this.result()?.search; const steps = search?.broadened ?? []; - if (!search || steps.length === 0) return null; + if (!search || steps.length === 0) {return null;} const parts: string[] = []; - if (steps.includes('keyword')) parts.push(`searched for “${search.keyword}” instead`); - if (steps.includes('area') && search.areaTitle) parts.push(`across ${search.areaTitle}`); + if (steps.includes('keyword')) {parts.push(`searched for “${search.keyword}” instead`);} + if (steps.includes('area') && search.areaTitle) {parts.push(`across ${search.areaTitle}`);} return `Nothing matched the first search, so we ${parts.join(' ')}.`; }); /** Set when no occupation is confirmed and the source was searched by the goal's target role. */