Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<CareerClient> UserAsync(bool occupation = true, bool preferDenver = false)
private async Task<CareerClient> 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();
Expand Down Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,22 @@ public sealed class FakeJobObservationSource : IJobObservationSource
public Exception? Failure { get; set; }
public JobObservationQuery? LastQuery { get; set; }
public int? ProviderTotal { get; set; }
/// <summary>Every query sent, in order.</summary>
public List<JobObservationQuery> Queries { get; } = new();
/// <summary>When set, only queries it accepts get <see cref="Raw"/>; others get an empty page.</summary>
public Func<JobObservationQuery, bool>? Answers { get; set; }

public Task<JobSourcePage> 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<RawJobObservation>(), 0));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,29 @@ public sealed record JobObservationResult(
JobSearchDto? Search = null);

/// <summary>What the source was searched with: the confirmed occupation, or the goal's target role until one is confirmed.</summary>
public sealed record JobSearchDto(string Basis, string Keyword);
public sealed record JobSearchDto(string Basis, string Keyword, string? AreaTitle = null, IReadOnlyList<string>? Broadened = null);

/// <summary>Which parts of the search were widened after the first one found nothing.</summary>
public static class JobSearchBroadening
{
public const string Keyword = "keyword";
public const string Area = "area";

private static readonly HashSet<string> 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"
};

/// <summary>The keyword without seniority words, lower-cased; null when nothing would change or nothing is left.</summary>
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
{
Expand Down
75 changes: 55 additions & 20 deletions AI.ProfilePhotoMaker.API/Services/Career/JobObservationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,29 +107,64 @@ public async Task<CareerOutcome<JobObservationResult>> 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<string>()) };
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<string>();
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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ <h1>Open postings</h1>
<a routerLink="/app/career/occupation">Confirm your occupation</a>
</div>
}
@if (broadenedNote(); as broadened) {
<p class="caution" role="status" data-search-broadened>{{ broadened }}</p>
}
@if (error()) {
<p role="alert" class="caution" data-error>{{ error() }}</p>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions AI.ProfilePhotoMaker.UI/tests/career-job-observations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
22 changes: 16 additions & 6 deletions AI.ProfilePhotoMaker.UI/tests/fixtures/career-job-observations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -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',
Expand Down
Loading