Skip to content

Tailor-résumé button hidden/under-informed when semantic JD-match view shows a real gap that keyword coverage doesn't #867

Description

@s-annam

Problem

On /jobs/'s paste-a-JD panel (PasteJdPanel.tsx), once a user opts into on-device semantic JD-match analysis and a semantic verdict list is on screen, the "Tailor résumé to this job" button's visibility and payload are still built exclusively from the keyword-coverage path. A user can be looking directly at a semantic "Missing" verdict for a real requirement gap and have no button to act on it — or have the button present but silently uninformed about the gap they can see.

This is a known, deliberately-scoped cut from #204/PR #866 (the semantic verdict UI), not an oversight in that PR — its own added comment says so explicitly. This issue is the tracked follow-up to close the gap, filed from the PR #866 review (gh-204-jdmatch-semantic-ui).

Concrete failure scenario

  1. User pastes a JD whose text literally contains a skill word (e.g. "Kubernetes") that also appears verbatim somewhere in their résumé — so keyword coverage counts it as covered, and coverage.missing is empty.
  2. User ticks "Analyze with on-device AI". The semantic judge evaluates the actual requirement behind that word (e.g. "3+ years operating production Kubernetes clusters") against the résumé's real content, not just the word's presence, and correctly returns status: "missing" with a grounded one-sentence reason.
  3. The SemanticMatch verdict list on screen shows that requirement under "Missing".
  4. buildJdRewriteContext(jdMatch.coverage) (keyword-only) sees coverage.missing.length === 0 and returns null, so the "Tailor résumé to this job" button does not render at all (onTailor && jdContext !== null gate) — the user has no way to steer a rewrite at the exact gap they're looking at.

The inverse also happens: a term is genuinely missing by keyword match but the semantic judge marks it partial (résumé shows adjacent experience) — the button renders with keyword-only steering text, silently ignoring the more nuanced semantic read the user opted in to get.

Root cause

  • src/components/features/PasteJdPanel.tsx:99displayed = semanticResult ?? jdMatch is what's rendered on screen (semantic verdicts replace the keyword columns once a semantic run finishes).
  • src/components/features/PasteJdPanel.tsx:111-113 — but jdContext (the button's gate AND payload) is computed as jdMatch === null ? null : buildJdRewriteContext(jdMatch.coverage) — always the keyword arm, regardless of what displayed currently is.
  • src/lib/jd-match/rewrite-context.ts:34-45buildJdRewriteContext(coverage: CoverageResult): string | null only knows how to read a keyword CoverageResult.missing: ExtractedTerm[]. There is no equivalent function that reads RequirementVerdict[] (the semantic result's verdicts field, src/lib/jd-match/llm/judge-evidence.ts:53-59: { requirement, status, reason, evidence? }).
  • The semantic result's shape (src/lib/jd-match/types.ts:47-48: { verdicts: readonly RequirementVerdict[], summary: SemanticMatchSummary }) is a superset of what the keyword path can express — it carries partial as a distinct status and a grounded reason/evidence, neither of which buildJdRewriteContext has any way to use.

Implementation plan

  1. Add a semantic-aware steering builder in src/lib/jd-match/rewrite-context.ts, sibling to buildJdRewriteContext:

    export function buildJdRewriteContextFromVerdicts(
      verdicts: readonly RequirementVerdict[],
    ): string | null {
      const gaps = verdicts
        .filter((v) => v.status === "missing" || v.status === "partial")
        .map((v) => v.requirement.text.trim())
        .filter((s) => s.length > 0)
        .slice(0, MAX_TERMS);
      if (gaps.length === 0) return null;
      // mirror buildJdRewriteContext's conservative, no-fabrication phrasing
      ...
    }

    Reuse MAX_TERMS and the existing no-fabrication phrasing pattern rather than inventing new copy — read buildJdRewriteContext's existing return statement first and match its voice.

  2. Wire it into PasteJdPanel.tsx. Replace the keyword-only jdContext useMemo (lines 111-113) with one that reads from whichever result is currently displayed:

    const jdContext = useMemo(() => {
      if (semanticResult !== null) {
        return buildJdRewriteContextFromVerdicts(semanticResult.verdicts);
      }
      return jdMatch === null ? null : buildJdRewriteContext(jdMatch.coverage);
    }, [semanticResult, jdMatch]);

    Update the comment block above it (currently states "Built from the KEYWORD coverage regardless of which view is on screen") to describe the new behavior instead of leaving stale reasoning in place.

  3. Same treatment for JobResultCard, if it has an equivalent tailor-button/jdContext wiring — check whether it can ever reach a semantic result (per PR feat(jd-match): semantic verdict UI, on-device opt-in, keyword fallback view (#204) #866's KeywordMatch/SemanticMatch docblocks, RankedJob.jdMatch is typed KeywordJdMatch only, so JobResultCard may be keyword-only by construction and this step may be a no-op; confirm before skipping).

  4. Tests (src/components/features/PasteJdPanel.semantic.test.tsx already has the mock scaffolding for semantic results from feat(jd-match): semantic verdict UI, on-device opt-in, keyword fallback view (#204) #866 — extend it):

    • Semantic result with missing/partial verdicts, keyword coverage fully covered → button renders, onTailor receives semantic-derived steering text (not null).
    • Semantic result with all verdicts met → button does not render (parity with today's coverage.missing.length === 0 behavior, just sourced from verdicts).
    • Keyword-only path (opt-in off, or opted-in but degraded to keyword) → unchanged behavior, still keyword-derived (regression guard).
    • Unit test for buildJdRewriteContextFromVerdicts in a new/extended rewrite-context.test.ts: empty verdicts → null; MAX_TERMS cap respected; partial and missing both included, met excluded.

Acceptance criteria

  • buildJdRewriteContextFromVerdicts exists in src/lib/jd-match/rewrite-context.ts, exported, unit-tested (empty → null, cap respected, only partial/missing included).
  • PasteJdPanel.tsx's jdContext is derived from semanticResult.verdicts when a semantic result is displayed, and falls back to keyword coverage otherwise — verified by a test where keyword coverage is fully covered but a semantic verdict is missing, and the button renders with non-null steering text.
  • Regression: with semantic opt-in off (or opted-in but degraded to keyword), jdContext behavior is byte-identical to pre-fix (existing PasteJdPanel.semantic.test.tsx / PasteJdPanel keyword tests still pass unmodified).
  • npm run typecheck, npm run lint, and the full PasteJdPanel/rewrite-context test files pass.
  • The stale "Built from the KEYWORD coverage regardless of which view is on screen" comment in PasteJdPanel.tsx is updated to match the new behavior (no misleading docblock left behind).

Reuse analysis

Capability: steer a résumé rewrite using JD-gap information. Existing surfaces found: buildJdRewriteContext (src/lib/jd-match/rewrite-context.ts) already owns this for the keyword path; PasteJdPanel.tsx's jdContext/"Tailor résumé to this job" Button already owns the UI trigger and is reused by JobResultCard per its own docblock. Decision: extend rewrite-context.ts with a sibling function for the semantic shape and extend PasteJdPanel.tsx's existing jdContext derivation — no new component, panel, or button. This is a data-source fix to an existing surface, not a new workflow surface, so no Reuse Gate concern beyond "don't hand-roll a second button," which this plan doesn't do.

Context

Surfaced during the /pr-review of #866 (feat(jd-match): semantic verdict UI, on-device opt-in, keyword fallback view (#204)), review comment: #866 (review) (Secondary finding #2). Not blocking for #866 — the cut was already disclosed in that PR's own code comments — but tracked here so it isn't lost.

Metadata

Metadata

Assignees

No one assigned

    Labels

    improvementEnhancing existing functionalityux:job-searchUX program: job-search relevance, filters, result set

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions