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 @@ -43,11 +43,21 @@ public class JobReviewQueryRepository {
+ "coalesce(w.status, 'UNREVIEWED') workflow_status, j.source, j.provider_tenant, "
+ "coalesce(j.published_at, j.first_seen_at) published_at, j.canonical_url";

/** The tail every read model shares: higher score, then newest, then stable job id. */
private static final String BY_SCORE = "coalesce(s.score, 0) desc, "
+ "coalesce(j.published_at, j.first_seen_at) desc, j.id desc";

/** UNREVIEWED first, then SAVED, then higher score, then newest, then stable job id. */
private static final String ORDER = " order by case coalesce(w.status, 'UNREVIEWED') "
+ "when 'UNREVIEWED' then 0 when 'SAVED' then 1 else 2 end, "
+ "coalesce(s.score, 0) desc, "
+ "coalesce(j.published_at, j.first_seen_at) desc, j.id desc";
+ "when 'UNREVIEWED' then 0 when 'SAVED' then 1 else 2 end, " + BY_SCORE;

/**
* The mixed Mini App review queue only: a confirmed MATCH outranks every REVIEW, and score
* orders within each disposition. Every row there is UNREVIEWED by construction, so the
* workflow bucket that leads {@link #ORDER} is constant and drops out.
*/
private static final String MATCH_FIRST_ORDER =
" order by case j.screening_disposition when 'MATCH' then 0 else 1 end, " + BY_SCORE;

private final NamedParameterJdbcTemplate jdbc;
private final ObjectMapper objectMapper;
Expand Down Expand Up @@ -96,12 +106,12 @@ public Optional<JobDetailView> findDetail(long id) {

/** Active vacancies that still need a decision; saved jobs never consume this window. */
public List<MiniAppJobRow> findMiniAppReviewJobs(int limit) {
return findMiniAppJobs(" and w.job_id is null", limit);
return findMiniAppJobs(" and w.job_id is null", MATCH_FIRST_ORDER, limit);
}

/** Durable saved vacancies in their own window, independent of review queue size. */
public List<MiniAppJobRow> findMiniAppSavedJobs(int limit) {
return findMiniAppJobs(" and w.status = 'SAVED'", limit);
return findMiniAppJobs(" and w.status = 'SAVED'", ORDER, limit);
}

/** Active detail rows for the bounded application page, loaded in one query. */
Expand All @@ -114,9 +124,9 @@ public List<MiniAppJobRow> findMiniAppJobsByIds(Collection<Long> jobIds) {
}

/** Everything selected is already in jobs/job_scores; no LLM or per-row query is involved. */
private List<MiniAppJobRow> findMiniAppJobs(String condition, int limit) {
private List<MiniAppJobRow> findMiniAppJobs(String condition, String order, int limit) {
String sql = COLUMNS + ", j.remote_type, j.seniority_level, j.employment_type, "
+ "s.band, s.strengths, s.risks" + FROM + " where " + ACTIVE + condition + ORDER
+ "s.band, s.strengths, s.risks" + FROM + " where " + ACTIVE + condition + order
+ " limit :limit";
return jdbc.query(sql, new MapSqlParameterSource("limit", limit), miniAppMapper());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,55 @@ void reviewQueueReportsItsAuthoritativeBoundary(int unreviewed) {
assertThat(page.truncated()).isEqualTo(unreviewed > MiniAppSnapshotService.MAX_REVIEW_JOBS);
}

/** Disposition is the outer key; score still orders inside each bucket, unchanged. */
@Test
void reviewQueuePrioritizesMatchBeforeHigherScoredReview() {
long weakMatch = active("match-weak", 10);
long strongMatch = active("match-strong", 44);
long weakReview = review("review-weak", 80);
long strongReview = review("review-strong", 99);

var items = snapshots.snapshot().reviewQueue().items();

assertThat(items)
.extracting(MiniAppJob::id)
.containsExactly(strongMatch, weakMatch, strongReview, weakReview);
}

/**
* The window is bounded, so MATCH-first decides who is <em>visible</em>, not merely who sits
* on top: a confirmed MATCH takes a slot from a higher-scored REVIEW rather than trailing it
* off the page. The page metadata still counts the whole pool, not the window.
*/
@Test
void boundedReviewWindowAdmitsAMatchAheadOfHigherScoredReviews() {
long weakMatch = active("match-weak", 5);
IntStream.range(0, MiniAppSnapshotService.MAX_REVIEW_JOBS - 1)
.forEach(index -> review("review-" + index, 100 - index));
long weakestReview = review("review-weakest", 50);

var page = snapshots.snapshot().reviewQueue();

assertThat(page.items()).hasSize(MiniAppSnapshotService.MAX_REVIEW_JOBS);
assertThat(page.items()).first().extracting(MiniAppJob::id).isEqualTo(weakMatch);
assertThat(page.items()).extracting(MiniAppJob::id).doesNotContain(weakestReview);
assertThat(page.total()).isEqualTo(MiniAppSnapshotService.MAX_REVIEW_JOBS + 1);
assertThat(page.truncated()).isTrue();
}

/** The Saved window keeps its own ordering: score decides there, disposition never does. */
@Test
void savedQueueIgnoresDispositionAndStaysScoreOrdered() {
long weakMatch = active("saved-match", 20);
long strongReview = review("saved-review", 90);
change(weakMatch, WorkflowStatus.SAVED);
change(strongReview, WorkflowStatus.SAVED);

assertThat(snapshots.snapshot().saved().items())
.extracting(MiniAppJob::id)
.containsExactly(strongReview, weakMatch);
}

@Test
void savedDoesNotCompeteWithFiftyOneUnreviewedVacancies() {
long saved = active("durable-saved", 1);
Expand Down Expand Up @@ -266,6 +315,13 @@ insert into job_scores (job_id, score, band, suitable, formal_eligibility,
return id;
}

/** The insert helper only produces MATCH; REVIEW vacancies are the same row, re-dispositioned. */
private long review(String externalId, int score) {
long id = active(externalId, score);
jdbc.update("update jobs set screening_disposition = 'REVIEW' where id = ?", id);
return id;
}

private int count(String table, String column, long id) {
return jdbc.queryForObject("select count(*) from " + table + " where " + column + " = ?",
Integer.class, id);
Expand Down
Loading