Skip to content

Commit 7a2b9dd

Browse files
LEANDERANTONYclaude
andcommitted
fix(workspace): persist parsed résumé pre-analysis + raise job page_size 12→50
Two issues from user testing: 1. "Every query returns exactly 12 jobs" — NOT an RPC/corpus cap. The search call in WorkspaceShell hardcoded `page_size: 12` (earlier grep missed it — it's the shell handler, not JobSearch.tsx). Backend already allows up to 50. Set to 50; resolves the exactly-12 mystery and the page_size decision in one line. 2. Parsed résumé lost on reload. Root cause: the workspace autosave only fires once an analysis exists (`!analysisState → return`), so a parsed-résumé-only state was never persisted; the resume-builder, by contrast, autosaves every turn. Fix brings the upload path to parity WITHOUT destabilising the working post-analysis save: - New ADDITIVE effect persists a minimal snapshot the moment a résumé parses (signed-in, no analysis yet). It calls `saveWorkspaceSnapshot` DIRECTLY, never `persistLatestWorkspace` (which sets workspaceSaveMeta and would gate-block the analysis autosave) — so the analysis path is wholly untouched. Ref-guarded by résumé identity (one save per distinct parse, best-effort, retry on failure). Single-row upsert ⇒ a later analysis save cleanly supersedes the provisional row. - `applySavedWorkspaceSnapshot` gains a résumé-only early-return: a real analysis snapshot always has job_description.raw_text; a résumé-only one has job_description:{}. Without this, restoring a résumé-only snapshot would `setAnalysisState({})` and render a broken empty "analysis" — worse than the original bug. - No backend change: `_validate_workspace_snapshot` already accepts the 5 required sections as empty {} (only checks isinstance dict); single upsert per user_id. New hermetic test (test_workspace_snapshot_validation.py, 7 cases) pins that contract so a future tightening can't silently regress this. Verified: backend contract test green; tsc + eslint clean. The authed restore UX (sign in → parse → reload → résumé back, no empty-analysis) can't be fully runtime-tested locally (authed flow) — eyeball on prod after deploy. Frontend + test only; no backend code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ba047b6 commit 7a2b9dd

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

frontend/src/components/workspace/WorkspaceShell.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
streamWorkspaceAssistantAnswer,
4545
updateResumeBuilderDraft,
4646
uploadJobDescriptionFile,
47+
saveWorkspaceSnapshot,
4748
uploadResumeFile,
4849
} from "@/lib/api";
4950
import type {
@@ -781,7 +782,9 @@ export function WorkspaceShell() {
781782
// dropdown's `remote` pick (either route returns the same rows).
782783
remote_only: workModes.length === 1 && workModes[0] === "remote",
783784
posted_within_days: postedWithinDays ? Number(postedWithinDays) : null,
784-
page_size: 12,
785+
// Was hardcoded 12 (the "every query returns exactly 12" cause —
786+
// it was never an RPC/corpus cap). Backend allows up to 50.
787+
page_size: 50,
785788
work_modes: workModes,
786789
employment_types: employmentTypes,
787790
sort_by: sortBy,
@@ -963,6 +966,25 @@ export function WorkspaceShell() {
963966
const snapshot = response.workspace_snapshot;
964967
if (!snapshot) return;
965968

969+
// Résumé-only snapshot: a parsed résumé persisted BEFORE any
970+
// analysis ran (see the résumé-autosave effect). A real analysis
971+
// snapshot always carries a job_description with raw_text; a
972+
// résumé-only one has job_description:{}. Restore JUST the résumé
973+
// and bail — calling setAnalysisState(snapshot) here would light
974+
// up an empty/broken "analysis" view, and snapshot.job_description
975+
// has no raw_text to read.
976+
const jd = snapshot.job_description as { raw_text?: string } | undefined;
977+
if (!jd || !jd.raw_text) {
978+
setResumeState({
979+
resume_document: snapshot.resume_document,
980+
candidate_profile: snapshot.candidate_profile,
981+
});
982+
setSelectedResumeFile(null);
983+
setResumeIntakeMode("upload");
984+
setMainTab("resume");
985+
return;
986+
}
987+
966988
setResumeState({
967989
resume_document: snapshot.resume_document,
968990
candidate_profile: snapshot.candidate_profile,
@@ -1622,6 +1644,63 @@ export function WorkspaceShell() {
16221644
});
16231645
}
16241646

1647+
// Persist a parsed résumé as soon as it exists — even before any
1648+
// analysis runs — so a tab reload / "Reload saved workspace"
1649+
// restores it (parity with the resume-builder, which already
1650+
// autosaves every turn). Deliberately calls saveWorkspaceSnapshot
1651+
// DIRECTLY, NOT persistLatestWorkspace: the latter sets
1652+
// workspaceSaveMeta, which would gate-block the post-analysis
1653+
// autosave effect below. This effect never touches workspaceSaveMeta,
1654+
// so the analysis path is wholly unaffected; the single-row upsert
1655+
// means a later analysis save cleanly supersedes this provisional
1656+
// row. Ref-guarded by résumé identity → fires once per distinct
1657+
// parsed résumé, never on every render.
1658+
const resumeAutoSavedRef = useRef<string | null>(null);
1659+
useEffect(() => {
1660+
if (
1661+
authStatus !== "signed_in" ||
1662+
!authSession?.features.saved_workspace_enabled ||
1663+
analysisState ||
1664+
!resumeState?.candidate_profile
1665+
) {
1666+
return;
1667+
}
1668+
const identity = `${resumeState.candidate_profile.full_name || ""}|${
1669+
resumeState.resume_document?.filetype || ""
1670+
}`;
1671+
if (resumeAutoSavedRef.current === identity) return;
1672+
resumeAutoSavedRef.current = identity;
1673+
// Minimal snapshot: real résumé sections + the other
1674+
// _validate_workspace_snapshot-required keys as {} (empty dicts
1675+
// pass its isinstance(dict) check). Cast because this is an
1676+
// intentionally-partial WorkspaceAnalysisResponse; the backend
1677+
// accepts any snapshot dict and only validates the 5 keys.
1678+
const snapshot = {
1679+
resume_document: resumeState.resume_document,
1680+
candidate_profile: resumeState.candidate_profile,
1681+
job_description: {},
1682+
jd_summary_view: {},
1683+
fit_analysis: {},
1684+
tailored_draft: {},
1685+
agent_result: null,
1686+
artifacts: {},
1687+
workflow: {},
1688+
} as unknown as WorkspaceAnalysisResponse;
1689+
void saveWorkspaceSnapshot(snapshot).catch(() => {
1690+
// Best-effort: a failed provisional save must not disrupt the
1691+
// workspace (the résumé is still in client state, and running
1692+
// the analysis will persist the full snapshot anyway). Clear
1693+
// the guard so a later change can retry.
1694+
resumeAutoSavedRef.current = null;
1695+
});
1696+
// eslint-disable-next-line react-hooks/exhaustive-deps -- fires once per distinct parsed résumé while signed-in with no analysis yet; the deps below fully describe that fire condition.
1697+
}, [
1698+
resumeState,
1699+
analysisState,
1700+
authStatus,
1701+
authSession?.features.saved_workspace_enabled,
1702+
]);
1703+
16251704
useEffect(() => {
16261705
if (
16271706
authStatus !== "signed_in" ||
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Contract test for `_validate_workspace_snapshot`.
2+
3+
The "persist a parsed résumé before any analysis runs" feature (so a
4+
tab reload restores it, parity with the resume-builder) depends on a
5+
specific, easily-broken contract: the saved-workspace validator must
6+
accept a *résumé-only* snapshot — real `candidate_profile` /
7+
`resume_document`, but the other required sections present only as
8+
empty dicts (no analysis has run yet). The frontend builds exactly
9+
that shape and POSTs it to /workspace/save.
10+
11+
If a future change makes `_validate_workspace_snapshot` require any of
12+
those sections to be *non-empty*, the provisional résumé save would
13+
start 4xx-ing silently and the reload-restore would regress. These
14+
hermetic tests (pure function, no Supabase) pin the contract.
15+
"""
16+
from __future__ import annotations
17+
18+
import pytest
19+
20+
from backend.services.workspace_persistence_service import (
21+
_validate_workspace_snapshot,
22+
)
23+
24+
# The 5 sections the validator requires to each be a dict.
25+
_REQUIRED = [
26+
"candidate_profile",
27+
"job_description",
28+
"fit_analysis",
29+
"tailored_draft",
30+
"artifacts",
31+
]
32+
33+
34+
def _resume_only_snapshot() -> dict:
35+
"""Mirrors what the frontend résumé-autosave effect sends: real
36+
résumé sections, the rest present as empty dicts."""
37+
return {
38+
"resume_document": {"filetype": "pdf", "text": "…"},
39+
"candidate_profile": {"full_name": "Leander Antony", "skills": ["Python"]},
40+
"job_description": {},
41+
"jd_summary_view": {},
42+
"fit_analysis": {},
43+
"tailored_draft": {},
44+
"agent_result": None,
45+
"artifacts": {},
46+
"workflow": {},
47+
}
48+
49+
50+
def test_resume_only_snapshot_passes_validation():
51+
# The load-bearing assertion: empty {} for the not-yet-existing
52+
# sections satisfies the isinstance(dict) check, so a parsed-résumé
53+
# snapshot persists fine before any analysis.
54+
payload = _validate_workspace_snapshot(_resume_only_snapshot())
55+
assert payload["candidate_profile"]["full_name"] == "Leander Antony"
56+
for section in _REQUIRED:
57+
assert isinstance(payload[section], dict)
58+
59+
60+
@pytest.mark.parametrize("missing", _REQUIRED)
61+
def test_missing_required_section_still_rejected(missing):
62+
# The relax is "empty dict is OK", NOT "anything goes" — a section
63+
# that isn't a dict at all must still raise (the validator stays a
64+
# real guard).
65+
snap = _resume_only_snapshot()
66+
snap[missing] = None # not a dict
67+
with pytest.raises(ValueError) as exc:
68+
_validate_workspace_snapshot(snap)
69+
assert missing in str(exc.value)
70+
71+
72+
def test_absent_section_rejected():
73+
snap = _resume_only_snapshot()
74+
del snap["artifacts"]
75+
with pytest.raises(ValueError):
76+
_validate_workspace_snapshot(snap)

0 commit comments

Comments
 (0)