Skip to content

Commit d636500

Browse files
committed
Harden workflow snapshot rehydration
1 parent 7fd1700 commit d636500

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

backend/services/workspace_run_jobs.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from typing import Any
88

99
from src.errors import AppError
10+
from src.logging_utils import get_logger, log_event
1011

1112
from backend.services.workspace_service import run_workspace_analysis
1213

1314

1415
JOB_TTL_SECONDS = 60 * 30
16+
LOGGER = get_logger(__name__)
1517

1618

1719
@dataclass
@@ -102,6 +104,15 @@ def _run_job(
102104
job.updated_at = time.time()
103105
except AppError as error:
104106
message = error.user_message
107+
log_event(
108+
LOGGER,
109+
30,
110+
"workspace_run_job_failed",
111+
"The background workspace analysis job failed with an application error.",
112+
job_id=job_id,
113+
error_type=type(error).__name__,
114+
message=message,
115+
)
105116
with _LOCK:
106117
job = _JOBS.get(job_id)
107118
if job is None:
@@ -110,6 +121,7 @@ def _run_job(
110121
job.error_message = message
111122
job.updated_at = time.time()
112123
except Exception as error: # pragma: no cover - defensive server fallback
124+
LOGGER.exception("Background workspace analysis job crashed.", extra={"job_id": job_id})
113125
with _LOCK:
114126
job = _JOBS.get(job_id)
115127
if job is None:

src/workflow_payloads.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@ def build_saved_workflow_snapshot_from_data(payload: dict):
286286
)
287287

288288

289+
def _as_dict(payload):
290+
return payload if isinstance(payload, dict) else {}
291+
292+
289293
def _build_imported_job_posting(payload):
290294
if not isinstance(payload, dict):
291295
return None
@@ -309,6 +313,7 @@ def _build_imported_job_posting(payload):
309313

310314

311315
def _build_candidate_profile(payload: dict):
316+
payload = _as_dict(payload)
312317
return CandidateProfile(
313318
full_name=str(payload.get("full_name", "") or ""),
314319
location=str(payload.get("location", "") or ""),
@@ -324,6 +329,7 @@ def _build_candidate_profile(payload: dict):
324329

325330

326331
def _build_work_experience(payload: dict):
332+
payload = _as_dict(payload)
327333
return WorkExperience(
328334
title=str(payload.get("title", "") or ""),
329335
organization=str(payload.get("organization", "") or ""),
@@ -335,6 +341,7 @@ def _build_work_experience(payload: dict):
335341

336342

337343
def _build_education_entry(payload: dict):
344+
payload = _as_dict(payload)
338345
return EducationEntry(
339346
institution=str(payload.get("institution", "") or ""),
340347
degree=str(payload.get("degree", "") or ""),
@@ -345,7 +352,9 @@ def _build_education_entry(payload: dict):
345352

346353

347354
def _build_job_description(payload: dict):
355+
payload = _as_dict(payload)
348356
requirements = payload.get("requirements") or {}
357+
requirements = _as_dict(requirements)
349358
return JobDescription(
350359
title=str(payload.get("title", "") or ""),
351360
raw_text=str(payload.get("raw_text", "") or ""),
@@ -362,6 +371,7 @@ def _build_job_description(payload: dict):
362371

363372

364373
def _build_fit_analysis(payload: dict):
374+
payload = _as_dict(payload)
365375
return FitAnalysis(
366376
target_role=str(payload.get("target_role", "") or ""),
367377
overall_score=int(payload.get("overall_score", 0) or 0),
@@ -378,6 +388,7 @@ def _build_fit_analysis(payload: dict):
378388

379389

380390
def _build_tailored_draft(payload: dict):
391+
payload = _as_dict(payload)
381392
return TailoredResumeDraft(
382393
target_role=str(payload.get("target_role", "") or ""),
383394
professional_summary=str(payload.get("professional_summary", "") or ""),
@@ -388,6 +399,7 @@ def _build_tailored_draft(payload: dict):
388399

389400

390401
def _build_profile_output(payload: dict):
402+
payload = _as_dict(payload)
391403
return ProfileAgentOutput(
392404
positioning_headline=str(payload.get("positioning_headline", "") or ""),
393405
evidence_highlights=[str(item) for item in payload.get("evidence_highlights", []) or []],
@@ -397,6 +409,7 @@ def _build_profile_output(payload: dict):
397409

398410

399411
def _build_job_output(payload: dict):
412+
payload = _as_dict(payload)
400413
return JobAgentOutput(
401414
requirement_summary=str(payload.get("requirement_summary", "") or ""),
402415
priority_skills=[str(item) for item in payload.get("priority_skills", []) or []],
@@ -406,6 +419,7 @@ def _build_job_output(payload: dict):
406419

407420

408421
def _build_fit_output(payload: dict):
422+
payload = _as_dict(payload)
409423
return FitAgentOutput(
410424
fit_summary=str(payload.get("fit_summary", "") or ""),
411425
top_matches=[str(item) for item in payload.get("top_matches", []) or []],
@@ -414,6 +428,7 @@ def _build_fit_output(payload: dict):
414428

415429

416430
def _build_tailoring_output(payload: dict):
431+
payload = _as_dict(payload)
417432
return TailoringAgentOutput(
418433
professional_summary=str(payload.get("professional_summary", "") or ""),
419434
rewritten_bullets=[str(item) for item in payload.get("rewritten_bullets", []) or []],
@@ -425,6 +440,7 @@ def _build_tailoring_output(payload: dict):
425440
def _build_strategy_output(payload):
426441
if not payload:
427442
return None
443+
payload = _as_dict(payload)
428444
return StrategyAgentOutput(
429445
recruiter_positioning=str(payload.get("recruiter_positioning", "") or ""),
430446
cover_letter_talking_points=[str(item) for item in payload.get("cover_letter_talking_points", []) or []],
@@ -433,6 +449,7 @@ def _build_strategy_output(payload):
433449

434450

435451
def _build_review_output(payload: dict):
452+
payload = _as_dict(payload)
436453
return ReviewAgentOutput(
437454
approved=bool(payload.get("approved", False)),
438455
grounding_issues=[str(item) for item in payload.get("grounding_issues", []) or []],
@@ -447,6 +464,7 @@ def _build_review_output(payload: dict):
447464
def _build_resume_generation_output(payload):
448465
if not payload:
449466
return None
467+
payload = _as_dict(payload)
450468
return ResumeGenerationAgentOutput(
451469
professional_summary=str(payload.get("professional_summary", "") or ""),
452470
highlighted_skills=[str(item) for item in payload.get("highlighted_skills", []) or []],
@@ -459,6 +477,7 @@ def _build_resume_generation_output(payload):
459477
def _build_cover_letter_output(payload):
460478
if not payload:
461479
return None
480+
payload = _as_dict(payload)
462481
return CoverLetterAgentOutput(
463482
greeting=str(payload.get("greeting", "") or ""),
464483
opening_paragraph=str(payload.get("opening_paragraph", "") or ""),
@@ -470,6 +489,7 @@ def _build_cover_letter_output(payload):
470489

471490

472491
def _build_review_pass_result(payload: dict):
492+
payload = _as_dict(payload)
473493
return ReviewPassResult(
474494
pass_index=int(payload.get("pass_index", 0) or 0),
475495
tailoring=_build_tailoring_output(payload.get("tailoring") or {}),
@@ -481,6 +501,7 @@ def _build_review_pass_result(payload: dict):
481501
def _build_agent_result(payload):
482502
if not payload:
483503
return None
504+
payload = _as_dict(payload)
484505
return AgentWorkflowResult(
485506
mode=str(payload.get("mode", "") or ""),
486507
model=str(payload.get("model", "") or ""),

tests/test_workflow_payloads.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from backend.services.artifact_export_service import preview_workspace_artifact
2+
from src.workflow_payloads import build_saved_workflow_snapshot_from_data
3+
4+
5+
def _malformed_workspace_snapshot():
6+
return {
7+
"candidate_profile": {
8+
"full_name": "Leander Antony",
9+
"location": "Chennai",
10+
"contact_lines": ["leander@example.com"],
11+
"source": "workspace",
12+
"resume_text": "Resume text",
13+
"skills": ["Python", "FastAPI"],
14+
"experience": [
15+
{
16+
"title": "Engineer",
17+
"organization": "Example Corp",
18+
"location": "Remote",
19+
"description": "Built features",
20+
"start": "2023",
21+
"end": "2024",
22+
},
23+
"unexpected-string-entry",
24+
],
25+
"education": [None],
26+
"certifications": [],
27+
"source_signals": [],
28+
},
29+
"job_description": {
30+
"title": "ML Engineer",
31+
"raw_text": "We need Python and FastAPI experience.",
32+
"cleaned_text": "We need Python and FastAPI experience.",
33+
"location": "Remote",
34+
"requirements": {
35+
"hard_skills": ["Python", "FastAPI"],
36+
"soft_skills": ["Communication"],
37+
"experience_requirement": "3+ years",
38+
"must_haves": ["APIs"],
39+
"nice_to_haves": ["LLMs"],
40+
},
41+
},
42+
"fit_analysis": {
43+
"target_role": "ML Engineer",
44+
"overall_score": 82,
45+
"readiness_label": "Strong",
46+
"matched_hard_skills": ["Python"],
47+
"missing_hard_skills": ["Kubernetes"],
48+
"matched_soft_skills": ["Communication"],
49+
"missing_soft_skills": [],
50+
"experience_signal": "Relevant backend work",
51+
"strengths": ["API design"],
52+
"gaps": ["Kubernetes"],
53+
"recommendations": ["Highlight deployment work"],
54+
},
55+
"tailored_draft": {
56+
"target_role": "ML Engineer",
57+
"professional_summary": "Backend engineer focused on ML tooling.",
58+
"highlighted_skills": ["Python", "FastAPI"],
59+
"priority_bullets": ["Built APIs for AI workflows"],
60+
"gap_mitigation_steps": ["Call out transferable infra work"],
61+
},
62+
"agent_result": {
63+
"mode": "agentic",
64+
"model": "gpt-test",
65+
"profile": {"positioning_headline": "ML engineer", "evidence_highlights": [], "strengths": [], "cautions": []},
66+
"job": {"requirement_summary": "Python-heavy role", "priority_skills": [], "must_have_themes": [], "messaging_guidance": []},
67+
"fit": {"fit_summary": "Good fit", "top_matches": [], "key_gaps": []},
68+
"tailoring": {
69+
"professional_summary": "Backend engineer focused on ML tooling.",
70+
"rewritten_bullets": ["Built APIs for AI workflows"],
71+
"highlighted_skills": ["Python", "FastAPI"],
72+
"cover_letter_themes": ["ownership"],
73+
},
74+
"review": {
75+
"approved": True,
76+
"grounding_issues": [],
77+
"unresolved_issues": [],
78+
"revision_requests": [],
79+
"final_notes": [],
80+
},
81+
"review_history": [None, "unexpected-review-entry"],
82+
},
83+
"imported_job_posting": None,
84+
}
85+
86+
87+
def test_build_saved_workflow_snapshot_tolerates_malformed_nested_items():
88+
snapshot = build_saved_workflow_snapshot_from_data(_malformed_workspace_snapshot())
89+
90+
assert snapshot is not None
91+
assert snapshot.candidate_profile.experience[1].title == ""
92+
assert snapshot.candidate_profile.education[0].institution == ""
93+
assert snapshot.agent_result is not None
94+
assert snapshot.agent_result.review_history[0].pass_index == 0
95+
assert snapshot.agent_result.review_history[1].pass_index == 0
96+
97+
98+
def test_preview_workspace_artifact_tolerates_malformed_nested_items():
99+
response = preview_workspace_artifact(
100+
workspace_snapshot=_malformed_workspace_snapshot(),
101+
artifact_kind="tailored_resume",
102+
resume_theme="classic_ats",
103+
)
104+
105+
assert response["status"] == "ready"
106+
assert "<html" in response["html"].lower()

0 commit comments

Comments
 (0)