diff --git a/src/main/java/com/jobpilot/jobreview/repository/JobReviewQueryRepository.java b/src/main/java/com/jobpilot/jobreview/repository/JobReviewQueryRepository.java index fc3c090..a801e30 100644 --- a/src/main/java/com/jobpilot/jobreview/repository/JobReviewQueryRepository.java +++ b/src/main/java/com/jobpilot/jobreview/repository/JobReviewQueryRepository.java @@ -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; @@ -96,12 +106,12 @@ public Optional findDetail(long id) { /** Active vacancies that still need a decision; saved jobs never consume this window. */ public List 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 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. */ @@ -114,9 +124,9 @@ public List findMiniAppJobsByIds(Collection jobIds) { } /** Everything selected is already in jobs/job_scores; no LLM or per-row query is involved. */ - private List findMiniAppJobs(String condition, int limit) { + private List 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()); } diff --git a/src/test/java/com/jobpilot/miniapp/application/MiniAppDurabilityIT.java b/src/test/java/com/jobpilot/miniapp/application/MiniAppDurabilityIT.java index 93f1cd9..6831570 100644 --- a/src/test/java/com/jobpilot/miniapp/application/MiniAppDurabilityIT.java +++ b/src/test/java/com/jobpilot/miniapp/application/MiniAppDurabilityIT.java @@ -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 visible, 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); @@ -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);