From a9f427d6b1d1f14f7bfb3f153ad01200ea82b8ad Mon Sep 17 00:00:00 2001 From: GwIhViEte <1007435607@qq.com> Date: Thu, 2 Jul 2026 19:58:24 +0800 Subject: [PATCH 1/4] feat: add interview prep workflow --- README.md | 6 +- apps/backend/app/database.py | 6 + apps/backend/app/db_engine.py | 7 + apps/backend/app/llm.py | 30 ++- apps/backend/app/models.py | 1 + apps/backend/app/prompts/__init__.py | 2 + apps/backend/app/prompts/templates.py | 53 +++++ apps/backend/app/routers/config.py | 4 + apps/backend/app/routers/resumes.py | 137 +++++++++++- apps/backend/app/schemas/__init__.py | 8 + apps/backend/app/schemas/models.py | 37 ++++ .../app/scripts/migrate_tinydb_to_sqlite.py | 1 + apps/backend/app/services/interview_prep.py | 33 +++ .../tests/integration/test_config_api.py | 10 +- .../tests/integration/test_resume_api.py | 137 ++++++++++++ apps/backend/tests/unit/test_database.py | 33 +++ .../tests/unit/test_interview_prep_service.py | 67 ++++++ .../tests/unit/test_prompt_guardrails.py | 13 +- .../app/(default)/resumes/[id]/page.tsx | 23 +- apps/frontend/app/(default)/settings/page.tsx | 19 +- .../components/builder/generate-prompt.tsx | 29 ++- .../builder/highlighted-resume-view.tsx | 12 +- .../builder/interview-prep-view.tsx | 202 ++++++++++++++++++ .../components/builder/resume-builder.tsx | 182 ++++++++++++++-- .../common/resume_previewer_context.tsx | 21 ++ .../resume/resume-modern-two-column.tsx | 19 +- .../components/resume/resume-modern.tsx | 16 +- .../resume/resume-single-column.tsx | 16 +- .../components/resume/resume-two-column.tsx | 23 +- apps/frontend/lib/api/config.ts | 2 + apps/frontend/lib/api/resume.ts | 17 +- apps/frontend/messages/en.json | 38 +++- apps/frontend/messages/es.json | 38 +++- apps/frontend/messages/ja.json | 38 +++- apps/frontend/messages/pt-BR.json | 38 +++- apps/frontend/messages/zh.json | 38 +++- .../tests/additional-info-newlines.test.tsx | 11 +- apps/frontend/tests/api-client.test.ts | 17 +- apps/frontend/tests/api-config.test.ts | 44 +++- apps/frontend/tests/api-resume.test.ts | 61 ++++++ .../frontend/tests/i18n-locale-parity.test.ts | 10 +- .../tests/interview-prep-view.test.tsx | 110 ++++++++++ apps/frontend/tests/section-helpers.test.ts | 20 +- docs/agent/apis/api-flow-maps.md | 16 +- docs/agent/apis/backend-requirements.md | 13 +- docs/agent/apis/front-end-apis.md | 3 + scripts/check_locale_parity.py | 33 +-- 47 files changed, 1558 insertions(+), 136 deletions(-) create mode 100644 apps/backend/app/services/interview_prep.py create mode 100644 apps/backend/tests/unit/test_interview_prep_service.py create mode 100644 apps/frontend/components/builder/interview-prep-view.tsx create mode 100644 apps/frontend/tests/api-resume.test.ts create mode 100644 apps/frontend/tests/interview-prep-view.test.tsx diff --git a/README.md b/README.md index 385725a05..4c294ac8c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Resume Matcher works by creating a master resume that you can use to tailor for 1. **Upload** your master resume (PDF or DOCX) 2. **Paste** a job description you're targeting 3. **Review** AI-generated improvements and tailored content -4. **Cover Letter** generator for the job application +4. **Cover Letter** and optional interview preparation for the job application 5. **Customize** the layout and sections to fit your style 6. **Export** as a professional PDF with your preferred template @@ -145,6 +145,10 @@ Generate tailored cover letters based on the job description and your resume. ![Cover Letter](assets/cover_letter.png) +### Interview Preparation + +Generate structured, resume-grounded interview prep for saved tailored resumes. Use the Builder's Interview Prep tab on demand, or enable automatic generation in Settings. + ### Resume Scoring & Keyword Highlighting Analyze your resume against the job description with a match score, keyword highlighting, and suggestions for improvement. diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index f47ad3980..26ac9f41b 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -126,6 +126,7 @@ def _resume_to_dict(row: Resume) -> dict[str, Any]: "processing_status": row.processing_status, "cover_letter": row.cover_letter, "outreach_message": row.outreach_message, + "interview_prep": row.interview_prep, "title": row.title, "created_at": row.created_at, "updated_at": row.updated_at, @@ -189,6 +190,7 @@ async def create_resume( processing_status: str = "pending", cover_letter: str | None = None, outreach_message: str | None = None, + interview_prep: str | None = None, title: str | None = None, original_markdown: str | None = None, ) -> dict[str, Any]: @@ -211,6 +213,7 @@ async def create_resume( processing_status=processing_status, cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=interview_prep, title=title, original_markdown=original_markdown, created_at=now, @@ -230,6 +233,7 @@ async def create_resume( "processing_status": processing_status, "cover_letter": cover_letter, "outreach_message": outreach_message, + "interview_prep": interview_prep, "title": title, "created_at": now, "updated_at": now, @@ -247,6 +251,7 @@ async def create_resume_atomic_master( processing_status: str = "pending", cover_letter: str | None = None, outreach_message: str | None = None, + interview_prep: str | None = None, original_markdown: str | None = None, title: str | None = None, ) -> dict[str, Any]: @@ -281,6 +286,7 @@ async def create_resume_atomic_master( processing_status=processing_status, cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=interview_prep, original_markdown=original_markdown, title=title, ) diff --git a/apps/backend/app/db_engine.py b/apps/backend/app/db_engine.py index 889738229..29b81a88c 100644 --- a/apps/backend/app/db_engine.py +++ b/apps/backend/app/db_engine.py @@ -62,3 +62,10 @@ def make_sync_engine(path: Path) -> Engine: def init_models_sync(engine: Engine) -> None: """Create all tables (idempotent) using a sync engine connection.""" Base.metadata.create_all(engine) + + # ``create_all`` does not ALTER existing SQLite tables. Keep this additive + # migration idempotent so older local databases can load resumes safely. + with engine.begin() as conn: + columns = conn.exec_driver_sql("PRAGMA table_info(resumes)").mappings().all() + if columns and "interview_prep" not in {column["name"] for column in columns}: + conn.exec_driver_sql("ALTER TABLE resumes ADD COLUMN interview_prep TEXT") diff --git a/apps/backend/app/llm.py b/apps/backend/app/llm.py index 20fea3be9..60a3dc55f 100644 --- a/apps/backend/app/llm.py +++ b/apps/backend/app/llm.py @@ -814,7 +814,8 @@ def _appears_truncated(data: dict, schema_type: str = "resume") -> bool: Args: data: Parsed JSON dict. schema_type: Expected schema — "resume" (full resume), "enrichment" - (analyze output), "diff" (diff changes), or "keywords". + (analyze output), "diff" (diff changes), "keywords", or + "interview_prep". Determines which fields are checked for truncation. """ if not isinstance(data, dict): @@ -844,6 +845,23 @@ def _appears_truncated(data: dict, schema_type: str = "resume") -> bool: return True return False + if schema_type == "interview_prep": + required = { + "role_fit_analysis", + "resume_questions", + "project_follow_ups", + "skill_gaps", + "talking_points", + } + missing = required - set(data) + if missing: + logging.warning( + "Possible truncation detected: interview_prep missing required keys: %s", + ", ".join(sorted(missing)), + ) + return True + return False + # For "diff", "keywords", and unknown schemas: no truncation heuristics. # Diff may legitimately return empty changes; keywords may return empty # lists when the job description has no actionable terms. @@ -1064,9 +1082,9 @@ async def complete_json( are handled by the Router and are NOT retried again here. Args: - schema_type: Expected schema — "resume", "enrichment", "diff", or - "keywords". Passed to _appears_truncated for context-aware truncation - detection and used to tailor retry hints. + schema_type: Expected schema — "resume", "enrichment", "diff", + "keywords", or "interview_prep". Passed to _appears_truncated for + context-aware truncation detection and used to tailor retry hints. """ router, config = get_router(config) model_name = get_model_name(config) @@ -1134,6 +1152,10 @@ async def complete_json( hint = ( "\n\nIMPORTANT: Output the COMPLETE JSON object with ALL keys: items_to_enrich, questions, analysis_summary. Do not truncate." ) + elif schema_type == "interview_prep": + hint = ( + "\n\nIMPORTANT: Output the COMPLETE JSON object with ALL keys: role_fit_analysis, resume_questions, project_follow_ups, skill_gaps, talking_points. Do not truncate." + ) else: hint = ( "\n\nIMPORTANT: Output ONLY a valid JSON object. Start with { and end with }." diff --git a/apps/backend/app/models.py b/apps/backend/app/models.py index 7d9e19bac..94b7b3ba2 100644 --- a/apps/backend/app/models.py +++ b/apps/backend/app/models.py @@ -42,6 +42,7 @@ class Resume(Base): processing_status: Mapped[str] = mapped_column(String, default="pending") cover_letter: Mapped[str | None] = mapped_column(Text, nullable=True) outreach_message: Mapped[str | None] = mapped_column(Text, nullable=True) + interview_prep: Mapped[str | None] = mapped_column(Text, nullable=True) title: Mapped[str | None] = mapped_column(String, nullable=True) # original_markdown has *absence* semantics in the TinyDB era: the key was # omitted entirely when None. The facade reproduces that by only emitting diff --git a/apps/backend/app/prompts/__init__.py b/apps/backend/app/prompts/__init__.py index ae2288665..ae5e4002d 100644 --- a/apps/backend/app/prompts/__init__.py +++ b/apps/backend/app/prompts/__init__.py @@ -10,6 +10,7 @@ IMPROVE_PROMPT_OPTIONS, IMPROVE_RESUME_PROMPT, IMPROVE_RESUME_PROMPTS, + INTERVIEW_PREP_PROMPT, PARSE_RESUME_PROMPT, SKILL_TARGET_PLAN_PROMPT, get_language_name, @@ -51,6 +52,7 @@ def validate_prompt_placeholders(prompt: str) -> list[str]: "DIFF_STRATEGY_INSTRUCTIONS", "SKILL_TARGET_PLAN_PROMPT", "GENERATE_TITLE_PROMPT", + "INTERVIEW_PREP_PROMPT", "REQUIRED_FEATURE_PROMPT_PLACEHOLDERS", "validate_prompt_placeholders", "get_language_name", diff --git a/apps/backend/app/prompts/templates.py b/apps/backend/app/prompts/templates.py index 64ae0bee2..5e7ac7aec 100644 --- a/apps/backend/app/prompts/templates.py +++ b/apps/backend/app/prompts/templates.py @@ -396,6 +396,59 @@ def _build_truthfulness_rules(rule_7: str) -> str: Output plain text only. No JSON, no markdown formatting.""" +INTERVIEW_PREP_PROMPT = """Generate structured interview preparation for this tailored resume and job. + +IMPORTANT: Write in {output_language}. + +Job Description: +{job_description} + +Candidate Resume (JSON): +{resume_data} + +Truthfulness guardrails: +- Use only evidence from the resume JSON and job description. +- Do NOT invent experience, tools, employers, metrics, certifications, skills, responsibilities, education, projects, or claims beyond the provided evidence. +- Do NOT imply the candidate has a skill or background unless it is present in the resume. +- Skill gaps are preparation targets only. They are not claimed candidate skills. +- If a job requirement is not evidenced by the resume, present it as something to prepare for or explain honestly. + +Return ONLY a valid JSON object with exactly these top-level keys: +{{ + "role_fit_analysis": ["Short evidence-based role-fit observation"], + "resume_questions": [ + {{ + "question": "Interview question grounded in the resume and job", + "focus_area": "Resume evidence or job requirement being tested", + "suggested_answer_points": ["Truthful point based on resume evidence"] + }} + ], + "project_follow_ups": [ + {{ + "question": "Follow-up question about a real resume project or experience", + "focus_area": "Project, impact, tradeoff, or implementation detail", + "suggested_answer_points": ["Truthful point based on resume evidence"] + }} + ], + "skill_gaps": [ + {{ + "skill": "Job-relevant skill or topic to prepare", + "why_it_matters": "Why this topic may come up for this role", + "preparation_suggestion": "How to prepare without claiming unsupported experience" + }} + ], + "talking_points": ["Concise role-specific talking point grounded in the resume"] +}} + +Content requirements: +- role_fit_analysis: 3-5 bullets. +- resume_questions: 5-8 questions. +- project_follow_ups: 3-6 questions. +- skill_gaps: 3-5 preparation targets. +- talking_points: 5-8 concise points. +- Keep all suggested answer points factual and resume-grounded. +- Do NOT use markdown fences or commentary outside the JSON.""" + GENERATE_TITLE_PROMPT = """Extract the job title and company name from this job description. IMPORTANT: Write in {output_language}. diff --git a/apps/backend/app/routers/config.py b/apps/backend/app/routers/config.py index 1995bafd1..9791fe54c 100644 --- a/apps/backend/app/routers/config.py +++ b/apps/backend/app/routers/config.py @@ -225,6 +225,7 @@ async def get_feature_config() -> FeatureConfigResponse: return FeatureConfigResponse( enable_cover_letter=stored.get("enable_cover_letter", False), enable_outreach_message=stored.get("enable_outreach_message", False), + enable_interview_prep=stored.get("enable_interview_prep", False), ) @@ -238,6 +239,8 @@ async def update_feature_config(request: FeatureConfigRequest) -> FeatureConfigR stored["enable_cover_letter"] = request.enable_cover_letter if request.enable_outreach_message is not None: stored["enable_outreach_message"] = request.enable_outreach_message + if request.enable_interview_prep is not None: + stored["enable_interview_prep"] = request.enable_interview_prep # Save config _save_config(stored) @@ -245,6 +248,7 @@ async def update_feature_config(request: FeatureConfigRequest) -> FeatureConfigR return FeatureConfigResponse( enable_cover_letter=stored.get("enable_cover_letter", False), enable_outreach_message=stored.get("enable_outreach_message", False), + enable_interview_prep=stored.get("enable_interview_prep", False), ) diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index 2fbbf35f9..a4ce4b37d 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -23,10 +23,12 @@ logger = logging.getLogger(__name__) from app.schemas import ( GenerateContentResponse, + GenerateInterviewPrepResponse, ImproveResumeConfirmRequest, ImproveResumeRequest, ImproveResumeResponse, ImproveResumeData, + InterviewPrepData, RefinementStats, ResumeDiffSummary, ResumeFieldDiff, @@ -61,6 +63,7 @@ generate_outreach_message, generate_resume_title, ) +from app.services.interview_prep import generate_interview_prep from app.prompts import DEFAULT_IMPROVE_PROMPT_ID, IMPROVE_PROMPT_OPTIONS @@ -120,6 +123,31 @@ def _normalize_payload(value: Any) -> Any: return value +def _serialize_interview_prep(interview_prep: InterviewPrepData | None) -> str | None: + if interview_prep is None: + return None + return json.dumps(interview_prep.model_dump(mode="json"), ensure_ascii=False) + + +def _parse_interview_prep( + raw: Any, + *, + resume_id: str | None = None, +) -> InterviewPrepData | None: + if raw in (None, ""): + return None + try: + payload = json.loads(raw) if isinstance(raw, str) else raw + return InterviewPrepData.model_validate(payload) + except (TypeError, json.JSONDecodeError, ValidationError, ValueError) as e: + logger.warning( + "Invalid interview_prep payload for resume %s: %s", + resume_id or "", + e, + ) + return None + + def _hash_improved_data(data: dict[str, Any]) -> str: """Hash canonicalized improved data for preview/confirm validation. @@ -491,16 +519,18 @@ async def _generate_auxiliary_messages( language: str, enable_cover_letter: bool, enable_outreach: bool, -) -> tuple[str | None, str | None, str | None, list[str]]: - """Generate cover letter, outreach message, and resume title. + enable_interview_prep: bool, +) -> tuple[str | None, str | None, str | None, InterviewPrepData | None, list[str]]: + """Generate cover letter, outreach, interview prep, and resume title. - Returns (cover_letter, outreach_message, title, warnings). + Returns (cover_letter, outreach_message, title, interview_prep, warnings). """ cover_letter = None outreach_message = None title = None + interview_prep = None warnings: list[str] = [] - generation_tasks: list[Awaitable[str]] = [] + generation_tasks: list[Awaitable[Any]] = [] task_labels: list[str] = [] # Title generation is always on (no feature flag) @@ -517,6 +547,11 @@ async def _generate_auxiliary_messages( generate_outreach_message(improved_data, job_content, language) ) task_labels.append("outreach") + if enable_interview_prep: + generation_tasks.append( + generate_interview_prep(improved_data, job_content, language) + ) + task_labels.append("interview_prep") results = await asyncio.gather(*generation_tasks, return_exceptions=True) for label, result in zip(task_labels, results): @@ -536,8 +571,10 @@ async def _generate_auxiliary_messages( cover_letter = result elif label == "outreach": outreach_message = result + elif label == "interview_prep": + interview_prep = result - return cover_letter, outreach_message, title, warnings + return cover_letter, outreach_message, title, interview_prep, warnings router = APIRouter(prefix="/resumes", tags=["Resumes"]) @@ -680,6 +717,10 @@ async def get_resume(resume_id: str = Query(...)) -> ResumeFetchResponse: processed_resume=processed_resume, cover_letter=resume.get("cover_letter"), outreach_message=resume.get("outreach_message"), + interview_prep=_parse_interview_prep( + resume.get("interview_prep"), + resume_id=resume_id, + ), parent_id=resume.get("parent_id"), title=resume.get("title"), ), @@ -1013,6 +1054,7 @@ async def _improve_preview_flow( markdownImproved=improved_text, cover_letter=None, outreach_message=None, + interview_prep=None, diff_summary=diff_summary, detailed_changes=detailed_changes, refinement_stats=refinement_stats, @@ -1039,6 +1081,7 @@ async def improve_resume_confirm_endpoint( feature_config = _load_config() enable_cover_letter = feature_config.get("enable_cover_letter", False) enable_outreach = feature_config.get("enable_outreach_message", False) + enable_interview_prep = feature_config.get("enable_interview_prep", False) language = get_content_language() stage = "serialize_improved_data" @@ -1101,6 +1144,7 @@ async def improve_resume_confirm_endpoint( cover_letter, outreach_message, title, + interview_prep, aux_warnings, ) = await _generate_auxiliary_messages( improved_data, @@ -1108,6 +1152,7 @@ async def improve_resume_confirm_endpoint( language, enable_cover_letter, enable_outreach, + enable_interview_prep, ) response_warnings.extend(aux_warnings) @@ -1122,6 +1167,7 @@ async def improve_resume_confirm_endpoint( processing_status="ready", cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=_serialize_interview_prep(interview_prep), title=title, ) @@ -1155,6 +1201,7 @@ async def improve_resume_confirm_endpoint( markdownImproved=improved_text, cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=interview_prep, diff_summary=diff_summary, detailed_changes=detailed_changes, warnings=response_warnings, @@ -1191,6 +1238,7 @@ async def improve_resume_endpoint( feature_config = _load_config() enable_cover_letter = feature_config.get("enable_cover_letter", False) enable_outreach = feature_config.get("enable_outreach_message", False) + enable_interview_prep = feature_config.get("enable_interview_prep", False) language = get_content_language() try: @@ -1339,6 +1387,7 @@ async def improve_resume_endpoint( cover_letter, outreach_message, title, + interview_prep, aux_warnings, ) = await _generate_auxiliary_messages( improved_data, @@ -1346,6 +1395,7 @@ async def improve_resume_endpoint( language, enable_cover_letter, enable_outreach, + enable_interview_prep, ) response_warnings.extend(aux_warnings) @@ -1360,6 +1410,7 @@ async def improve_resume_endpoint( processing_status="ready", cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=_serialize_interview_prep(interview_prep), title=title, ) @@ -1398,6 +1449,7 @@ async def improve_resume_endpoint( markdownImproved=improved_text, cover_letter=cover_letter, outreach_message=outreach_message, + interview_prep=interview_prep, # Diff metadata diff_summary=diff_summary, detailed_changes=detailed_changes, @@ -1461,6 +1513,14 @@ async def update_resume_endpoint( resume_id=resume_id, raw_resume=raw_resume, processed_resume=processed_resume, + cover_letter=updated.get("cover_letter"), + outreach_message=updated.get("outreach_message"), + interview_prep=_parse_interview_prep( + updated.get("interview_prep"), + resume_id=resume_id, + ), + parent_id=updated.get("parent_id"), + title=updated.get("title"), ), ) @@ -1791,6 +1851,73 @@ async def generate_outreach_endpoint(resume_id: str) -> GenerateContentResponse: ) +@router.post( + "/{resume_id}/generate-interview-prep", + response_model=GenerateInterviewPrepResponse, +) +async def generate_interview_prep_endpoint( + resume_id: str, +) -> GenerateInterviewPrepResponse: + """Generate interview preparation on-demand for an existing tailored resume.""" + resume = await db.get_resume(resume_id) + if not resume: + raise HTTPException(status_code=404, detail="Resume not found") + + if not resume.get("parent_id"): + raise HTTPException( + status_code=400, + detail="Interview preparation can only be generated for tailored resumes. " + "Please tailor this resume to a job description first.", + ) + + improvement = await db.get_improvement_by_tailored_resume(resume_id) + if not improvement: + raise HTTPException( + status_code=400, + detail="No job context found for this resume. " + "The resume may have been created before job tracking was implemented.", + ) + + job = await db.get_job(improvement["job_id"]) + if not job: + raise HTTPException( + status_code=404, + detail="The associated job description was not found.", + ) + + resume_data = resume.get("processed_data") + if not resume_data: + raise HTTPException( + status_code=400, + detail="Resume has no processed data. Please re-upload the resume.", + ) + + language = get_content_language() + + try: + interview_prep = await generate_interview_prep( + resume_data, + job["content"], + language, + ) + except Exception as e: + logger.error("Interview preparation generation failed: %s", e) + raise HTTPException( + status_code=500, + detail="Failed to generate interview preparation. Please try again.", + ) + + await db.update_resume( + resume_id, + {"interview_prep": _serialize_interview_prep(interview_prep)}, + ) + + return GenerateInterviewPrepResponse( + interview_prep=interview_prep, + message="Interview preparation generated successfully", + ) + + @router.get("/{resume_id}/job-description") async def get_job_description_for_resume(resume_id: str) -> dict: """Get the job description used to tailor this resume. diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py index 243de4be2..b3fad4e0b 100644 --- a/apps/backend/app/schemas/__init__.py +++ b/apps/backend/app/schemas/__init__.py @@ -15,6 +15,7 @@ FeaturePromptsRequest, FeaturePromptsResponse, GenerateContentResponse, + GenerateInterviewPrepResponse, HealthResponse, ImproveDiffResult, ImprovementSuggestion, @@ -28,6 +29,9 @@ LanguageConfigResponse, LLMConfigRequest, LLMConfigResponse, + InterviewPrepData, + InterviewPrepQuestion, + InterviewPrepSkillGap, normalize_resume_data, PersonalInfo, Project, @@ -107,6 +111,9 @@ "FeatureConfigResponse", "FeaturePromptsRequest", "FeaturePromptsResponse", + "InterviewPrepData", + "InterviewPrepQuestion", + "InterviewPrepSkillGap", "ApiKeyProviderStatus", "ApiKeyStatusResponse", "ApiKeysUpdateRequest", @@ -116,6 +123,7 @@ "UpdateOutreachMessageRequest", "UpdateTitleRequest", "GenerateContentResponse", + "GenerateInterviewPrepResponse", "HealthResponse", "StatusResponse", "ApplicationStatus", diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index e66a4fb64..747d112eb 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -380,6 +380,32 @@ class RawResume(BaseModel): processing_status: str = "pending" # pending, processing, ready, failed +class InterviewPrepQuestion(BaseModel): + """Interview question grounded in the tailored resume and job context.""" + + question: str + focus_area: str | None = None + suggested_answer_points: list[str] = Field(default_factory=list) + + +class InterviewPrepSkillGap(BaseModel): + """A preparation target, not a claimed candidate skill.""" + + skill: str + why_it_matters: str + preparation_suggestion: str + + +class InterviewPrepData(BaseModel): + """Structured interview preparation content for a tailored resume.""" + + role_fit_analysis: list[str] + resume_questions: list[InterviewPrepQuestion] + project_follow_ups: list[InterviewPrepQuestion] + skill_gaps: list[InterviewPrepSkillGap] + talking_points: list[str] + + class ResumeFetchData(BaseModel): """Data payload for resume fetch response.""" @@ -388,6 +414,7 @@ class ResumeFetchData(BaseModel): processed_resume: ResumeData | None = None cover_letter: str | None = None outreach_message: str | None = None + interview_prep: InterviewPrepData | None = None parent_id: str | None = None # For determining if resume is tailored title: str | None = None @@ -522,6 +549,7 @@ class ImproveResumeData(BaseModel): markdownImproved: str | None = None cover_letter: str | None = None outreach_message: str | None = None + interview_prep: InterviewPrepData | None = None # Diff metadata diff_summary: ResumeDiffSummary | None = None @@ -588,6 +616,7 @@ class FeatureConfigRequest(BaseModel): enable_cover_letter: bool | None = None enable_outreach_message: bool | None = None + enable_interview_prep: bool | None = None class FeatureConfigResponse(BaseModel): @@ -595,6 +624,7 @@ class FeatureConfigResponse(BaseModel): enable_cover_letter: bool = False enable_outreach_message: bool = False + enable_interview_prep: bool = False class LanguageConfigRequest(BaseModel): @@ -727,6 +757,13 @@ class GenerateContentResponse(BaseModel): message: str +class GenerateInterviewPrepResponse(BaseModel): + """Response for on-demand interview preparation generation.""" + + interview_prep: InterviewPrepData + message: str + + # Health/Status Models class HealthResponse(BaseModel): """Health check response.""" diff --git a/apps/backend/app/scripts/migrate_tinydb_to_sqlite.py b/apps/backend/app/scripts/migrate_tinydb_to_sqlite.py index 9c2e6a69c..c841426f3 100644 --- a/apps/backend/app/scripts/migrate_tinydb_to_sqlite.py +++ b/apps/backend/app/scripts/migrate_tinydb_to_sqlite.py @@ -82,6 +82,7 @@ async def migrate(database: Database | None = None) -> dict[str, Any]: processing_status=r.get("processing_status", "pending"), cover_letter=r.get("cover_letter"), outreach_message=r.get("outreach_message"), + interview_prep=r.get("interview_prep"), title=r.get("title"), original_markdown=r.get("original_markdown"), created_at=r.get("created_at") or _utcnow_iso(), diff --git a/apps/backend/app/services/interview_prep.py b/apps/backend/app/services/interview_prep.py new file mode 100644 index 000000000..6a4819281 --- /dev/null +++ b/apps/backend/app/services/interview_prep.py @@ -0,0 +1,33 @@ +"""Interview preparation generation service.""" + +import json +from typing import Any + +from app.llm import complete_json +from app.prompts import INTERVIEW_PREP_PROMPT, get_language_name +from app.schemas import InterviewPrepData + + +async def generate_interview_prep( + resume_data: dict[str, Any], + job_description: str, + language: str = "en", +) -> InterviewPrepData: + """Generate structured interview preparation for a tailored resume.""" + prompt = INTERVIEW_PREP_PROMPT.format( + job_description=job_description, + resume_data=json.dumps(resume_data, ensure_ascii=False), + output_language=get_language_name(language), + ) + + result = await complete_json( + prompt=prompt, + system_prompt=( + "You are a career interview coach. Output truthful, resume-grounded " + "interview preparation as JSON only." + ), + max_tokens=4096, + schema_type="interview_prep", + ) + + return InterviewPrepData.model_validate(result) diff --git a/apps/backend/tests/integration/test_config_api.py b/apps/backend/tests/integration/test_config_api.py index 0642ceb91..390565d1f 100644 --- a/apps/backend/tests/integration/test_config_api.py +++ b/apps/backend/tests/integration/test_config_api.py @@ -240,6 +240,7 @@ async def test_get_features(self, mock_load, client): mock_load.return_value = { "enable_cover_letter": True, "enable_outreach_message": False, + "enable_interview_prep": True, } async with client: resp = await client.get("/api/v1/config/features") @@ -247,6 +248,7 @@ async def test_get_features(self, mock_load, client): data = resp.json() assert data["enable_cover_letter"] is True assert data["enable_outreach_message"] is False + assert data["enable_interview_prep"] is True @patch("app.routers.config._save_config") @patch("app.routers.config._load_config") @@ -255,9 +257,15 @@ async def test_put_features(self, mock_load, mock_save, client): async with client: resp = await client.put("/api/v1/config/features", json={ "enable_cover_letter": True, + "enable_interview_prep": True, }) assert resp.status_code == 200 - assert resp.json()["enable_cover_letter"] is True + data = resp.json() + assert data["enable_cover_letter"] is True + assert data["enable_interview_prep"] is True + saved = mock_save.call_args.args[0] + assert saved["enable_cover_letter"] is True + assert saved["enable_interview_prep"] is True class TestFeaturePrompts: diff --git a/apps/backend/tests/integration/test_resume_api.py b/apps/backend/tests/integration/test_resume_api.py index db6cb3f4e..aa86dcb85 100644 --- a/apps/backend/tests/integration/test_resume_api.py +++ b/apps/backend/tests/integration/test_resume_api.py @@ -1,5 +1,6 @@ """Integration tests for resume CRUD endpoints.""" +import json from unittest.mock import patch, AsyncMock, MagicMock from uuid import uuid4 @@ -7,6 +8,34 @@ from httpx import ASGITransport, AsyncClient from app.main import app +from app.schemas import InterviewPrepData + + +SAMPLE_INTERVIEW_PREP = { + "role_fit_analysis": ["Backend API experience maps to the role."], + "resume_questions": [ + { + "question": "How did you design the FastAPI service on your resume?", + "focus_area": "Backend architecture", + "suggested_answer_points": ["Discuss the documented API work only."], + } + ], + "project_follow_ups": [ + { + "question": "What tradeoffs did you make in the resume matcher project?", + "focus_area": "Project implementation", + "suggested_answer_points": ["Explain real project choices from the resume."], + } + ], + "skill_gaps": [ + { + "skill": "Kubernetes", + "why_it_matters": "The job description mentions production deployment.", + "preparation_suggestion": "Review core concepts without claiming production use.", + } + ], + "talking_points": ["Connect API work to the job's backend requirements."], +} @pytest.fixture @@ -29,6 +58,7 @@ def mock_resume_record(sample_resume): "processing_status": "ready", "cover_letter": None, "outreach_message": None, + "interview_prep": None, "title": None, "original_markdown": "# Jane Doe\nSenior Backend Engineer", "created_at": "2026-01-01T00:00:00Z", @@ -49,6 +79,20 @@ async def test_fetch_existing_resume(self, mock_db, client, mock_resume_record): assert data["resume_id"] == "res-123" assert data["processed_resume"] is not None assert data["processed_resume"]["summary"] != "" + assert data["interview_prep"] is None + + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_invalid_interview_prep_does_not_break_fetch( + self, mock_db, client, mock_resume_record + ): + mock_db.get_resume.return_value = { + **mock_resume_record, + "interview_prep": "{not-json", + } + async with client: + resp = await client.get("/api/v1/resumes", params={"resume_id": "res-123"}) + assert resp.status_code == 200 + assert resp.json()["data"]["interview_prep"] is None @patch("app.routers.resumes.db", new_callable=AsyncMock) async def test_fetch_nonexistent_returns_404(self, mock_db, client): @@ -148,6 +192,99 @@ async def test_update_outreach(self, mock_db, client, mock_resume_record): assert resp.status_code == 200 +class TestGenerateInterviewPrep: + """POST /api/v1/resumes/{resume_id}/generate-interview-prep""" + + @patch("app.routers.resumes.get_content_language", return_value="en") + @patch("app.routers.resumes.generate_interview_prep", new_callable=AsyncMock) + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_success_saves_structured_json( + self, mock_db, mock_generate, _mock_language, client, mock_resume_record, sample_resume + ): + tailored = { + **mock_resume_record, + "parent_id": "master-1", + "processed_data": sample_resume, + } + mock_db.get_resume.return_value = tailored + mock_db.get_improvement_by_tailored_resume.return_value = {"job_id": "job-1"} + mock_db.get_job.return_value = {"job_id": "job-1", "content": "Need FastAPI"} + mock_generate.return_value = InterviewPrepData.model_validate(SAMPLE_INTERVIEW_PREP) + + async with client: + resp = await client.post("/api/v1/resumes/res-123/generate-interview-prep") + + assert resp.status_code == 200 + data = resp.json() + assert data["message"] == "Interview preparation generated successfully" + assert data["interview_prep"]["role_fit_analysis"] == SAMPLE_INTERVIEW_PREP[ + "role_fit_analysis" + ] + mock_generate.assert_awaited_once_with(sample_resume, "Need FastAPI", "en") + update_payload = mock_db.update_resume.await_args.args[1] + saved_payload = json.loads(update_payload["interview_prep"]) + assert saved_payload == SAMPLE_INTERVIEW_PREP + + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_rejects_non_tailored_resume(self, mock_db, client, mock_resume_record): + mock_db.get_resume.return_value = mock_resume_record + + async with client: + resp = await client.post("/api/v1/resumes/res-123/generate-interview-prep") + + assert resp.status_code == 400 + assert "tailored resumes" in resp.json()["detail"] + + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_rejects_missing_improvement_context( + self, mock_db, client, mock_resume_record + ): + mock_db.get_resume.return_value = {**mock_resume_record, "parent_id": "master-1"} + mock_db.get_improvement_by_tailored_resume.return_value = None + + async with client: + resp = await client.post("/api/v1/resumes/res-123/generate-interview-prep") + + assert resp.status_code == 400 + assert "No job context" in resp.json()["detail"] + + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_rejects_missing_processed_data(self, mock_db, client, mock_resume_record): + mock_db.get_resume.return_value = { + **mock_resume_record, + "parent_id": "master-1", + "processed_data": None, + } + mock_db.get_improvement_by_tailored_resume.return_value = {"job_id": "job-1"} + mock_db.get_job.return_value = {"job_id": "job-1", "content": "Need FastAPI"} + + async with client: + resp = await client.post("/api/v1/resumes/res-123/generate-interview-prep") + + assert resp.status_code == 400 + assert "processed data" in resp.json()["detail"] + + @patch("app.routers.resumes.generate_interview_prep", new_callable=AsyncMock) + @patch("app.routers.resumes.db", new_callable=AsyncMock) + async def test_generation_failure_returns_500( + self, mock_db, mock_generate, client, mock_resume_record, sample_resume + ): + mock_db.get_resume.return_value = { + **mock_resume_record, + "parent_id": "master-1", + "processed_data": sample_resume, + } + mock_db.get_improvement_by_tailored_resume.return_value = {"job_id": "job-1"} + mock_db.get_job.return_value = {"job_id": "job-1", "content": "Need FastAPI"} + mock_generate.side_effect = RuntimeError("llm failed") + + async with client: + resp = await client.post("/api/v1/resumes/res-123/generate-interview-prep") + + assert resp.status_code == 500 + assert "Failed to generate interview preparation" in resp.json()["detail"] + + class TestRetryProcessing: """POST /api/v1/resumes/{resume_id}/retry-processing""" diff --git a/apps/backend/tests/unit/test_database.py b/apps/backend/tests/unit/test_database.py index bf1e87d17..0856744d2 100644 --- a/apps/backend/tests/unit/test_database.py +++ b/apps/backend/tests/unit/test_database.py @@ -9,6 +9,7 @@ import pytest from app.database import Database +from app.db_engine import init_models_sync, make_sync_engine @pytest.fixture @@ -61,6 +62,38 @@ async def test_original_markdown_absence_semantics(self, db): fetched = await db.get_resume(with_md["resume_id"]) assert fetched["original_markdown"] == "# raw" + async def test_interview_prep_round_trips_as_text(self, db): + created = await db.create_resume( + content="x", + interview_prep='{"role_fit_analysis":["fit"]}', + ) + fetched = await db.get_resume(created["resume_id"]) + assert fetched["interview_prep"] == '{"role_fit_analysis":["fit"]}' + + def test_interview_prep_migration_is_idempotent(self, tmp_path): + engine = make_sync_engine(tmp_path / "old.db") + try: + with engine.begin() as conn: + conn.exec_driver_sql( + """ + CREATE TABLE resumes ( + resume_id TEXT PRIMARY KEY, + content TEXT NOT NULL, + content_type TEXT DEFAULT 'md' + ) + """ + ) + + init_models_sync(engine) + init_models_sync(engine) + + with engine.begin() as conn: + columns = conn.exec_driver_sql("PRAGMA table_info(resumes)").mappings().all() + names = [column["name"] for column in columns] + assert names.count("interview_prep") == 1 + finally: + engine.dispose() + class TestMasterResume: async def test_no_master_initially(self, db): diff --git a/apps/backend/tests/unit/test_interview_prep_service.py b/apps/backend/tests/unit/test_interview_prep_service.py new file mode 100644 index 000000000..7372f3032 --- /dev/null +++ b/apps/backend/tests/unit/test_interview_prep_service.py @@ -0,0 +1,67 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import ValidationError + +from app.services.interview_prep import generate_interview_prep + + +SAMPLE_RESUME = { + "personalInfo": {"name": "Jane Doe"}, + "summary": "Backend engineer", + "workExperience": [], + "education": [], + "personalProjects": [], + "additional": {"technicalSkills": ["Python", "FastAPI"]}, +} + + +def _valid_payload(): + return { + "role_fit_analysis": ["Python API experience is relevant."], + "resume_questions": [ + { + "question": "How did you build the API?", + "focus_area": "Backend APIs", + "suggested_answer_points": ["Use resume-grounded API details."], + } + ], + "project_follow_ups": [], + "skill_gaps": [ + { + "skill": "Kubernetes", + "why_it_matters": "The JD mentions deployment.", + "preparation_suggestion": "Review basics without claiming experience.", + } + ], + "talking_points": ["Connect FastAPI work to the role."], + } + + +@pytest.mark.asyncio +async def test_generate_interview_prep_validates_successful_json(): + with patch( + "app.services.interview_prep.complete_json", + new_callable=AsyncMock, + ) as mock_complete: + mock_complete.return_value = _valid_payload() + + result = await generate_interview_prep(SAMPLE_RESUME, "Need FastAPI", "en") + + assert result.role_fit_analysis == ["Python API experience is relevant."] + mock_complete.assert_awaited_once() + assert mock_complete.await_args.kwargs["schema_type"] == "interview_prep" + + +@pytest.mark.asyncio +async def test_generate_interview_prep_rejects_malformed_llm_json(): + with patch( + "app.services.interview_prep.complete_json", + new_callable=AsyncMock, + ) as mock_complete: + mock_complete.return_value = { + "role_fit_analysis": ["Only one required key is present."] + } + + with pytest.raises(ValidationError): + await generate_interview_prep(SAMPLE_RESUME, "Need FastAPI", "en") diff --git a/apps/backend/tests/unit/test_prompt_guardrails.py b/apps/backend/tests/unit/test_prompt_guardrails.py index 4b4aaa401..73b208973 100644 --- a/apps/backend/tests/unit/test_prompt_guardrails.py +++ b/apps/backend/tests/unit/test_prompt_guardrails.py @@ -9,7 +9,11 @@ ONLY guard. If a future edit drops them, this test fails loudly. """ -from app.prompts.templates import COVER_LETTER_PROMPT, DIFF_IMPROVE_PROMPT +from app.prompts.templates import ( + COVER_LETTER_PROMPT, + DIFF_IMPROVE_PROMPT, + INTERVIEW_PREP_PROMPT, +) from app.prompts.refinement import KEYWORD_INJECTION_PROMPT @@ -40,3 +44,10 @@ def test_keyword_injection_keeps_no_invent_clauses(self): def test_cover_letter_keeps_no_invent_clauses(self): assert "Do NOT invent information not in the resume" in COVER_LETTER_PROMPT assert "proven experience supports it" in COVER_LETTER_PROMPT + + def test_interview_prep_keeps_no_fabrication_guardrails(self): + assert "Do NOT invent experience" in INTERVIEW_PREP_PROMPT + assert "tools, employers, metrics, certifications, skills" in INTERVIEW_PREP_PROMPT + assert "Skill gaps are preparation targets only" in INTERVIEW_PREP_PROMPT + assert "role_fit_analysis" in INTERVIEW_PREP_PROMPT + assert "talking_points" in INTERVIEW_PREP_PROMPT diff --git a/apps/frontend/app/(default)/resumes/[id]/page.tsx b/apps/frontend/app/(default)/resumes/[id]/page.tsx index 5b9c69dc4..fcdd5d529 100644 --- a/apps/frontend/app/(default)/resumes/[id]/page.tsx +++ b/apps/frontend/app/(default)/resumes/[id]/page.tsx @@ -14,7 +14,16 @@ import { renameResume, } from '@/lib/api/resume'; import { useStatusCache } from '@/lib/context/status-cache'; -import { ArrowLeft, Edit, Download, Loader2, AlertCircle, Sparkles, Pencil } from 'lucide-react'; +import { + ArrowLeft, + Edit, + Download, + Loader2, + AlertCircle, + Sparkles, + Pencil, + MessagesSquare, +} from 'lucide-react'; import { EnrichmentModal } from '@/components/enrichment/enrichment-modal'; import { useTranslations } from '@/lib/i18n'; import { withLocalizedDefaultSections } from '@/lib/utils/section-helpers'; @@ -44,6 +53,7 @@ export default function ResumeViewerPage() { const [resumeTitle, setResumeTitle] = useState(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editingTitleValue, setEditingTitleValue] = useState(''); + const [isTailoredResume, setIsTailoredResume] = useState(false); const resumeId = params?.id as string; @@ -67,6 +77,7 @@ export default function ResumeViewerPage() { // Capture title for editable display (always set to clear stale state) setResumeTitle(data.title ?? null); + setIsTailoredResume(Boolean(data.parent_id)); // Prioritize processed_resume if available (structured JSON) if (data.processed_resume) { @@ -122,6 +133,10 @@ export default function ResumeViewerPage() { router.push(`/builder?id=${resumeId}`); }; + const handleInterviewPrep = () => { + router.push(`/builder?id=${resumeId}&tab=interview-prep`); + }; + const handleTitleSave = async () => { const trimmed = editingTitleValue.trim(); if (!trimmed || trimmed === resumeTitle) { @@ -360,6 +375,12 @@ export default function ResumeViewerPage() { {t('dashboard.editResume')} + {isTailoredResume && ( + + )}

- {isOutreach - ? t('builder.generatePrompt.outreachFooter') - : t('builder.generatePrompt.coverLetterFooter')} + {isInterviewPrep + ? t('builder.generatePrompt.interviewPrepFooter') + : isOutreach + ? t('builder.generatePrompt.outreachFooter') + : t('builder.generatePrompt.coverLetterFooter')}

); diff --git a/apps/frontend/components/builder/highlighted-resume-view.tsx b/apps/frontend/components/builder/highlighted-resume-view.tsx index 42035308e..146e1d87f 100644 --- a/apps/frontend/components/builder/highlighted-resume-view.tsx +++ b/apps/frontend/components/builder/highlighted-resume-view.tsx @@ -21,11 +21,17 @@ export function HighlightedResumeView({ resumeData, keywords }: HighlightedResum // Drop blank/whitespace-only entries so empty lines (e.g. from editing in the // builder) never render in the preview (issue #763). const visibleTechnicalSkills = - resumeData.additional?.technicalSkills?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + resumeData.additional?.technicalSkills?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; const visibleLanguages = - resumeData.additional?.languages?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + resumeData.additional?.languages?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; const visibleCertificationsTraining = - resumeData.additional?.certificationsTraining?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + resumeData.additional?.certificationsTraining?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; return (
diff --git a/apps/frontend/components/builder/interview-prep-view.tsx b/apps/frontend/components/builder/interview-prep-view.tsx new file mode 100644 index 000000000..77c7705c9 --- /dev/null +++ b/apps/frontend/components/builder/interview-prep-view.tsx @@ -0,0 +1,202 @@ +'use client'; + +import * as React from 'react'; +import { AlertTriangle, Lightbulb, ListChecks, MessageSquareText, Target } from 'lucide-react'; +import { GeneratePrompt } from './generate-prompt'; +import type { + InterviewPrepData, + InterviewPrepQuestion, + InterviewPrepSkillGap, +} from '@/components/common/resume_previewer_context'; +import { cn } from '@/lib/utils'; +import { useTranslations } from '@/lib/i18n'; + +interface InterviewPrepViewProps { + interviewPrep: InterviewPrepData | null; + isGenerating: boolean; + error?: string | null; + onGenerate: () => void; + isTailoredResume: boolean; + canGenerate?: boolean; + unavailableMessage?: string | null; + className?: string; +} + +function Section({ + title, + icon: Icon, + children, +}: { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} + +function StringList({ items }: { items: string[] }) { + if (!items.length) return null; + return ( +
    + {items.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+ ); +} + +function QuestionList({ items }: { items: InterviewPrepQuestion[] }) { + const { t } = useTranslations(); + + if (!items.length) return null; + return ( +
+ {items.map((item, index) => ( +
+

{item.question}

+ {item.focus_area && ( +

+ {t('interviewPrep.focusArea')}: {item.focus_area} +

+ )} + {item.suggested_answer_points.length > 0 && ( +
+

+ {t('interviewPrep.suggestedAnswerPoints')} +

+ +
+ )} +
+ ))} +
+ ); +} + +function SkillGapList({ items }: { items: InterviewPrepSkillGap[] }) { + const { t } = useTranslations(); + + if (!items.length) return null; + return ( +
+ {items.map((item, index) => ( +
+

{item.skill}

+
+

+ + {t('interviewPrep.whyItMatters')}:{' '} + + {item.why_it_matters} +

+

+ + {t('interviewPrep.preparationSuggestion')}:{' '} + + {item.preparation_suggestion} +

+
+
+ ))} +
+ ); +} + +export function InterviewPrepView({ + interviewPrep, + isGenerating, + error, + onGenerate, + isTailoredResume, + canGenerate = true, + unavailableMessage, + className, +}: InterviewPrepViewProps) { + const { t } = useTranslations(); + + if (!interviewPrep) { + return ( +
+ {error && ( +
+ +

{error}

+
+ )} + {isTailoredResume && !canGenerate ? ( +
+
+ +
+

+ {t('interviewPrep.unavailableTitle')} +

+

+ {unavailableMessage ?? t('interviewPrep.missingContextDescription')} +

+
+ ) : ( + + )} +
+ ); + } + + return ( +
+ {error && ( +
+ +

{error}

+
+ )} + {isTailoredResume && !canGenerate && unavailableMessage && ( +
+ +

{unavailableMessage}

+
+ )} + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/apps/frontend/components/builder/resume-builder.tsx b/apps/frontend/components/builder/resume-builder.tsx index 2a9569ec1..f518a2499 100644 --- a/apps/frontend/components/builder/resume-builder.tsx +++ b/apps/frontend/components/builder/resume-builder.tsx @@ -11,6 +11,7 @@ import { OutreachEditor } from './outreach-editor'; import { CoverLetterPreview } from './cover-letter-preview'; import { OutreachPreview } from './outreach-preview'; import { GeneratePrompt } from './generate-prompt'; +import { InterviewPrepView } from './interview-prep-view'; import { Button } from '@/components/ui/button'; import { RetroTabs } from '@/components/ui/retro-tabs'; import { ConfirmDialog, type ConfirmDialogProps } from '@/components/ui/confirm-dialog'; @@ -25,7 +26,10 @@ import { Sparkles, Loader2, } from 'lucide-react'; -import { useResumePreview } from '@/components/common/resume_previewer_context'; +import { + useResumePreview, + type InterviewPrepData, +} from '@/components/common/resume_previewer_context'; import { PaginatedPreview } from '@/components/preview'; import { downloadResumePdf, @@ -38,6 +42,7 @@ import { updateOutreachMessage, generateCoverLetter, generateOutreachMessage, + generateInterviewPrep, fetchJobDescription, } from '@/lib/api/resume'; import { JDComparisonView } from './jd-comparison-view'; @@ -50,13 +55,20 @@ import { useLanguage } from '@/lib/context/language-context'; import { buildResumeFilename, downloadBlobAsFile, openUrlInNewTab } from '@/lib/utils/download'; import type { RegenerateItemInput } from '@/lib/api/enrichment'; -type TabId = 'resume' | 'cover-letter' | 'outreach' | 'jd-match'; +type TabId = 'resume' | 'cover-letter' | 'outreach' | 'interview-prep' | 'jd-match'; +type JobContextStatus = 'idle' | 'loading' | 'available' | 'missing'; const STORAGE_KEY = 'resume_builder_draft'; const SETTINGS_STORAGE_KEY = 'resume_builder_settings'; +const TAB_IDS: TabId[] = ['resume', 'cover-letter', 'outreach', 'interview-prep', 'jd-match']; type Translate = (key: string, params?: Record) => string; +const getTabFromSearchParams = (searchParams: Pick): TabId => { + const tab = searchParams.get('tab'); + return TAB_IDS.includes(tab as TabId) ? (tab as TabId) : 'resume'; +}; + const buildInitialData = (t: Translate): ResumeData => ({ personalInfo: { name: t('builder.personalInfoForm.placeholders.name'), @@ -118,6 +130,7 @@ const ResumeBuilderContent = () => { const improvedPreview = improvedData?.data?.resume_preview; const improvedCoverLetter = improvedData?.data?.cover_letter; const improvedOutreach = improvedData?.data?.outreach_message; + const improvedInterviewPrep = improvedData?.data?.interview_prep ?? null; const searchParams = useSearchParams(); const router = useRouter(); const resumeId = searchParams.get('id'); @@ -135,11 +148,16 @@ const ResumeBuilderContent = () => { }, [initialData, resumeId, hasUnsavedChanges, improvedPreview]); // Tab state - const [activeTab, setActiveTab] = useState('resume'); + const [activeTab, setActiveTab] = useState(() => getTabFromSearchParams(searchParams)); + + useEffect(() => { + setActiveTab(getTabFromSearchParams(searchParams)); + }, [searchParams]); // Cover letter & outreach state const [coverLetter, setCoverLetter] = useState(''); const [outreachMessage, setOutreachMessage] = useState(''); + const [interviewPrep, setInterviewPrep] = useState(null); const [isCoverLetterSaving, setIsCoverLetterSaving] = useState(false); const [isOutreachSaving, setIsOutreachSaving] = useState(false); const [isCopied, setIsCopied] = useState(false); @@ -149,12 +167,15 @@ const ResumeBuilderContent = () => { const [isTailoredResume, setIsTailoredResume] = useState(false); const [isGeneratingCoverLetter, setIsGeneratingCoverLetter] = useState(false); const [isGeneratingOutreach, setIsGeneratingOutreach] = useState(false); + const [isGeneratingInterviewPrep, setIsGeneratingInterviewPrep] = useState(false); + const [interviewPrepError, setInterviewPrepError] = useState(null); const [showRegenerateDialog, setShowRegenerateDialog] = useState< - 'cover-letter' | 'outreach' | null + 'cover-letter' | 'outreach' | 'interview-prep' | null >(null); // JD comparison state const [jobDescription, setJobDescription] = useState(null); + const [jobContextStatus, setJobContextStatus] = useState('idle'); // AI Regenerate wizard const regenerateWizard = useRegenerateWizard({ @@ -297,6 +318,8 @@ const ResumeBuilderContent = () => { if (data.outreach_message) { setOutreachMessage(data.outreach_message); } + setInterviewPrep(data.interview_prep ?? null); + setInterviewPrepError(null); // Prefer processed_resume if available if (data.processed_resume) { setResumeData(data.processed_resume as ResumeData); @@ -323,6 +346,7 @@ const ResumeBuilderContent = () => { // Priority 2: Improved Data from Context (Tailor Flow) if (improvedPreview) { + setIsTailoredResume(Boolean(improvedData?.data?.resume_id && improvedData.data.job_id)); setResumeData(improvedPreview); setLastSavedData(improvedPreview); // Also load cover letter and outreach if present @@ -332,6 +356,8 @@ const ResumeBuilderContent = () => { if (improvedOutreach) { setOutreachMessage(improvedOutreach); } + setInterviewPrep(improvedInterviewPrep); + setInterviewPrepError(null); // Persist to localStorage as backup localStorage.setItem(STORAGE_KEY, JSON.stringify(improvedPreview)); setLoadingState('loaded'); @@ -358,7 +384,15 @@ const ResumeBuilderContent = () => { }; loadResumeData(); - }, [improvedPreview, improvedCoverLetter, improvedOutreach, resumeId]); + }, [ + improvedData?.data?.job_id, + improvedData?.data?.resume_id, + improvedPreview, + improvedCoverLetter, + improvedOutreach, + improvedInterviewPrep, + resumeId, + ]); // Fetch job description when we have a tailored resume useEffect(() => { @@ -366,21 +400,26 @@ const ResumeBuilderContent = () => { const loadJobDescription = async () => { if (isTailoredResume && resumeId) { + setJobDescription(null); + setJobContextStatus('loading'); try { const data = await fetchJobDescription(resumeId); if (!cancelled) { setJobDescription(data.content); + setJobContextStatus('available'); } } catch (err) { // JD might not be available for older resumes if (!cancelled) { console.warn('Could not fetch job description:', err); setJobDescription(null); + setJobContextStatus('missing'); } } } else { // Clear job description when switching to non-tailored resume setJobDescription(null); + setJobContextStatus('idle'); } }; @@ -602,6 +641,73 @@ const ResumeBuilderContent = () => { doGenerateOutreach(); }; + const canGenerateInterviewPrep = + Boolean(resumeId) && isTailoredResume && jobContextStatus === 'available'; + + const interviewPrepUnavailableMessage = !resumeId + ? t('interviewPrep.saveRequiredDescription') + : jobContextStatus === 'loading' + ? t('interviewPrep.loadingContextDescription') + : jobContextStatus === 'missing' + ? t('interviewPrep.missingContextDescription') + : null; + + const doGenerateInterviewPrep = async () => { + if (!canGenerateInterviewPrep || !resumeId) return; + setIsGeneratingInterviewPrep(true); + setInterviewPrepError(null); + setShowRegenerateDialog(null); + try { + const content = await generateInterviewPrep(resumeId); + setInterviewPrep(content); + } catch (error) { + console.error('Failed to generate interview preparation:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setInterviewPrepError( + t('builder.alerts.interviewPrepGenerateFailed', { error: errorMessage }) + ); + showNotification( + t('builder.alerts.interviewPrepGenerateFailed', { error: errorMessage }), + 'danger' + ); + } finally { + setIsGeneratingInterviewPrep(false); + } + }; + + const handleGenerateInterviewPrep = () => { + if (!canGenerateInterviewPrep) return; + if (interviewPrep) { + setShowRegenerateDialog('interview-prep'); + return; + } + doGenerateInterviewPrep(); + }; + + const regenerateDialogContentTitle = + showRegenerateDialog === 'cover-letter' + ? t('coverLetter.title') + : showRegenerateDialog === 'outreach' + ? t('outreach.title') + : t('interviewPrep.title'); + + const regenerateDialogConfirmLabel = + showRegenerateDialog === 'cover-letter' + ? t('coverLetter.regenerate') + : showRegenerateDialog === 'outreach' + ? t('outreach.regenerate') + : t('interviewPrep.regenerate'); + + const handleConfirmRegenerate = () => { + if (showRegenerateDialog === 'cover-letter') { + doGenerateCoverLetter(); + } else if (showRegenerateDialog === 'outreach') { + doGenerateOutreach(); + } else if (showRegenerateDialog === 'interview-prep') { + doGenerateInterviewPrep(); + } + }; + return (
{/* Main Container */} @@ -733,6 +839,23 @@ const ResumeBuilderContent = () => { )} + + {/* Interview prep tab actions */} + {activeTab === 'interview-prep' && interviewPrep && ( + + )}
@@ -748,6 +871,7 @@ const ResumeBuilderContent = () => { {activeTab === 'resume' && t('builder.leftPanel.editorPanel')} {activeTab === 'cover-letter' && t('builder.leftPanel.coverLetterEditor')} {activeTab === 'outreach' && t('builder.leftPanel.outreachEditor')} + {activeTab === 'interview-prep' && t('builder.leftPanel.interviewPrep')} {activeTab === 'jd-match' && t('builder.leftPanel.jdMatchAnalysis')} @@ -796,6 +920,20 @@ const ResumeBuilderContent = () => { /> ))} + {/* Interview Prep Read-Only View */} + {activeTab === 'interview-prep' && ( + + )} + {/* JD Match Info Panel */} {activeTab === 'jd-match' && (
@@ -864,6 +1002,11 @@ const ResumeBuilderContent = () => { label: t('builder.previewTabs.outreach'), disabled: !outreachMessage, }, + { + id: 'interview-prep', + label: t('builder.previewTabs.interviewPrep'), + disabled: !isTailoredResume, + }, { id: 'jd-match', label: t('builder.previewTabs.jdMatch'), @@ -919,6 +1062,19 @@ const ResumeBuilderContent = () => { /> ))} + {/* Interview Prep Preview */} + {activeTab === 'interview-prep' && ( + + )} + {/* JD Match Comparison */} {activeTab === 'jd-match' && jobDescription && ( @@ -964,23 +1120,15 @@ const ResumeBuilderContent = () => { open={showRegenerateDialog !== null} onOpenChange={(open) => !open && setShowRegenerateDialog(null)} title={t('builder.regenerateDialog.title', { - title: - showRegenerateDialog === 'cover-letter' ? t('coverLetter.title') : t('outreach.title'), + title: regenerateDialogContentTitle, })} description={t('builder.regenerateDialog.description', { - title: - showRegenerateDialog === 'cover-letter' ? t('coverLetter.title') : t('outreach.title'), + title: regenerateDialogContentTitle, })} - confirmLabel={ - showRegenerateDialog === 'cover-letter' - ? t('coverLetter.regenerate') - : t('outreach.regenerate') - } + confirmLabel={regenerateDialogConfirmLabel} cancelLabel={t('common.cancel')} variant="warning" - onConfirm={ - showRegenerateDialog === 'cover-letter' ? doGenerateCoverLetter : doGenerateOutreach - } + onConfirm={handleConfirmRegenerate} /> {/* Notification Dialog (replaces native alert()) */} diff --git a/apps/frontend/components/common/resume_previewer_context.tsx b/apps/frontend/components/common/resume_previewer_context.tsx index cf3bffad9..cdded5946 100644 --- a/apps/frontend/components/common/resume_previewer_context.tsx +++ b/apps/frontend/components/common/resume_previewer_context.tsx @@ -81,6 +81,26 @@ export interface ResumePreview { additional: AdditionalInfo; } +export interface InterviewPrepQuestion { + question: string; + focus_area?: string | null; + suggested_answer_points: string[]; +} + +export interface InterviewPrepSkillGap { + skill: string; + why_it_matters: string; + preparation_suggestion: string; +} + +export interface InterviewPrepData { + role_fit_analysis: string[]; + resume_questions: InterviewPrepQuestion[]; + project_follow_ups: InterviewPrepQuestion[]; + skill_gaps: InterviewPrepSkillGap[]; + talking_points: string[]; +} + export interface Data { request_id: string; resume_id: string | null; @@ -98,6 +118,7 @@ export interface Data { job_keywords?: string; cover_letter?: string; outreach_message?: string; + interview_prep?: InterviewPrepData | null; diff_summary?: ResumeDiffSummary; detailed_changes?: ResumeFieldDiff[]; } diff --git a/apps/frontend/components/resume/resume-modern-two-column.tsx b/apps/frontend/components/resume/resume-modern-two-column.tsx index e5f614d53..57bcd14f8 100644 --- a/apps/frontend/components/resume/resume-modern-two-column.tsx +++ b/apps/frontend/components/resume/resume-modern-two-column.tsx @@ -40,11 +40,22 @@ export const ResumeModernTwoColumn: React.FC = ({ // Drop blank/whitespace-only entries so empty lines (e.g. from editing in the // builder) never render in the resume or PDF (issue #763). - const technicalSkills = additional?.technicalSkills?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; - const languages = additional?.languages?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + const technicalSkills = + additional?.technicalSkills?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; + const languages = + additional?.languages?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; const certificationsTraining = - additional?.certificationsTraining?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; - const awards = additional?.awards?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + additional?.certificationsTraining?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; + const awards = + additional?.awards?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; // Get sorted visible sections const sortedSections = getSortedSections(data); diff --git a/apps/frontend/components/resume/resume-modern.tsx b/apps/frontend/components/resume/resume-modern.tsx index 570c97f5c..9114d0334 100644 --- a/apps/frontend/components/resume/resume-modern.tsx +++ b/apps/frontend/components/resume/resume-modern.tsx @@ -376,10 +376,18 @@ const AdditionalSection: React.FC<{ // Drop blank/whitespace-only entries so empty lines (e.g. from editing in the // builder) never render in the resume or PDF (issue #763). - const technicalSkills = rawTechnicalSkills.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const languages = rawLanguages.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const certificationsTraining = rawCertificationsTraining.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const awards = rawAwards.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); + const technicalSkills = rawTechnicalSkills.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const languages = rawLanguages.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const certificationsTraining = rawCertificationsTraining.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const awards = rawAwards.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); const mergedLabels: AdditionalSectionLabels = { technicalSkills: labels?.technicalSkills ?? 'Technical Skills:', diff --git a/apps/frontend/components/resume/resume-single-column.tsx b/apps/frontend/components/resume/resume-single-column.tsx index 2cee2eecb..32d868e2b 100644 --- a/apps/frontend/components/resume/resume-single-column.tsx +++ b/apps/frontend/components/resume/resume-single-column.tsx @@ -374,10 +374,18 @@ const AdditionalSection: React.FC<{ // Drop blank/whitespace-only entries so empty lines (e.g. from editing in the // builder) never render in the resume or PDF (issue #763). - const technicalSkills = rawTechnicalSkills.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const languages = rawLanguages.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const certificationsTraining = rawCertificationsTraining.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); - const awards = rawAwards.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); + const technicalSkills = rawTechnicalSkills.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const languages = rawLanguages.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const certificationsTraining = rawCertificationsTraining.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); + const awards = rawAwards.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ); const mergedLabels: AdditionalSectionLabels = { technicalSkills: labels?.technicalSkills ?? 'Technical Skills:', diff --git a/apps/frontend/components/resume/resume-two-column.tsx b/apps/frontend/components/resume/resume-two-column.tsx index c8ab39c49..92e73f762 100644 --- a/apps/frontend/components/resume/resume-two-column.tsx +++ b/apps/frontend/components/resume/resume-two-column.tsx @@ -34,11 +34,22 @@ export const ResumeTwoColumn: React.FC = ({ // Drop blank/whitespace-only entries so empty lines (e.g. from editing in the // builder) never render in the resume or PDF (issue #763). - const technicalSkills = additional?.technicalSkills?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; - const languages = additional?.languages?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + const technicalSkills = + additional?.technicalSkills?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; + const languages = + additional?.languages?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; const certificationsTraining = - additional?.certificationsTraining?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; - const awards = additional?.awards?.filter((item): item is string => typeof item === 'string' && item.trim() !== '') ?? []; + additional?.certificationsTraining?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; + const awards = + additional?.awards?.filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) ?? []; // Get sorted visible sections const sortedSections = getSortedSections(data); @@ -418,7 +429,9 @@ export const ResumeTwoColumn: React.FC = ({ {/* Languages Section */} {isSectionVisible('additional') && languages.length > 0 && (
-

{headingFallbacks.languages}

+

+ {headingFallbacks.languages} +

{languages.join(' • ')}

)} diff --git a/apps/frontend/lib/api/config.ts b/apps/frontend/lib/api/config.ts index f44e3d3c7..051c2b291 100644 --- a/apps/frontend/lib/api/config.ts +++ b/apps/frontend/lib/api/config.ts @@ -165,11 +165,13 @@ export const PROVIDER_INFO: Record< export interface FeatureConfig { enable_cover_letter: boolean; enable_outreach_message: boolean; + enable_interview_prep: boolean; } export interface FeatureConfigUpdate { enable_cover_letter?: boolean; enable_outreach_message?: boolean; + enable_interview_prep?: boolean; } // Fetch feature configuration diff --git a/apps/frontend/lib/api/resume.ts b/apps/frontend/lib/api/resume.ts index 3e5585f89..98d3fe228 100644 --- a/apps/frontend/lib/api/resume.ts +++ b/apps/frontend/lib/api/resume.ts @@ -1,4 +1,7 @@ -import { ImprovedResult } from '@/components/common/resume_previewer_context'; +import type { + ImprovedResult, + InterviewPrepData, +} from '@/components/common/resume_previewer_context'; import type { ResumeData } from '@/components/dashboard/resume-component'; import { type TemplateSettings } from '@/lib/types/template-settings'; import { type Locale } from '@/i18n/config'; @@ -63,6 +66,7 @@ interface ResumeResponse { processed_resume: ProcessedResume | null; cover_letter?: string | null; outreach_message?: string | null; + interview_prep?: InterviewPrepData | null; parent_id?: string | null; // For determining if resume is tailored title?: string | null; }; @@ -355,6 +359,17 @@ export async function generateOutreachMessage(resumeId: string): Promise return data.content; } +/** Generates interview preparation on-demand for a tailored resume */ +export async function generateInterviewPrep(resumeId: string): Promise { + const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/generate-interview-prep`, {}); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to generate interview preparation (status ${res.status}): ${text}`); + } + const data = (await res.json()) as { interview_prep: InterviewPrepData }; + return data.interview_prep; +} + /** Retries AI processing for a failed resume */ export async function retryProcessing(resumeId: string): Promise { const res = await apiPost(`/resumes/${encodeURIComponent(resumeId)}/retry-processing`, {}); diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index c10ad5e20..14d9f2664 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -176,7 +176,8 @@ "resume": "RESUME", "coverLetter": "COVER LETTER", "outreach": "OUTREACH MAIL", - "jdMatch": "JD MATCH" + "jdMatch": "JD MATCH", + "interviewPrep": "INTERVIEW PREP" }, "footer": { "moduleLabel": "Resume Builder Module", @@ -195,7 +196,9 @@ "outreachDescription": "Generate a concise outreach message for LinkedIn or email based on your resume and job description.", "generateButton": "Generate {title}", "coverLetterFooter": "Tip: tailor first for best results.", - "outreachFooter": "Tip: keep it short and personalized." + "outreachFooter": "Tip: keep it short and personalized.", + "interviewPrepDescription": "Generate role-specific interview preparation based on your tailored resume and job description.", + "interviewPrepFooter": "Tip: use this after tailoring to prepare truthful, resume-grounded answers." }, "regenerateDialog": { "title": "Regenerate {title}?", @@ -444,7 +447,8 @@ "editorPanel": "Editor Panel", "coverLetterEditor": "Cover Letter Editor", "outreachEditor": "Outreach Message Editor", - "jdMatchAnalysis": "JD Match Analysis" + "jdMatchAnalysis": "JD Match Analysis", + "interviewPrep": "Interview Prep" }, "alerts": { "saveNotAvailable": "Save is only available when editing an existing resume.", @@ -461,7 +465,8 @@ "coverLetterDownloadFailed": "Failed to download cover letter: {error}", "outreachSaveFailed": "Failed to save outreach message. Please try again.", "coverLetterGenerateFailed": "Failed to generate cover letter: {error}", - "outreachGenerateFailed": "Failed to generate outreach message: {error}" + "outreachGenerateFailed": "Failed to generate outreach message: {error}", + "interviewPrepGenerateFailed": "Failed to generate interview preparation: {error}" } }, "enrichment": { @@ -705,7 +710,11 @@ "customPromptLabel": "Custom prompt (optional)", "customPromptHelp": "Must include these placeholders (each in curly braces): job_description, resume_data, output_language. Leave blank to use the default prompt.", "customPromptResetButton": "Reset to default", - "customPromptErrorMissing": "Missing required placeholders: {missing}" + "customPromptErrorMissing": "Missing required placeholders: {missing}", + "interviewPrep": { + "label": "Interview Preparation", + "description": "Automatically generate interview preparation alongside saved tailored resumes" + } }, "promptSettings": { "title": "Prompt Settings", @@ -1006,5 +1015,24 @@ "moveFailed": "Could not move the application.", "deleteFailed": "Could not delete the application(s)." } + }, + "interviewPrep": { + "title": "Interview Prep", + "regenerate": "Regenerate", + "unavailableTitle": "Interview Prep Not Available", + "loadingContextDescription": "Checking the job context for this tailored resume before generation.", + "missingContextDescription": "This tailored resume does not have the saved job context required to generate interview preparation. Re-tailor the resume with a job description to use this workflow.", + "saveRequiredDescription": "Save this tailored resume before generating interview preparation.", + "focusArea": "Focus area", + "suggestedAnswerPoints": "Suggested answer points", + "whyItMatters": "Why it matters", + "preparationSuggestion": "Preparation suggestion", + "sections": { + "roleFit": "Role-Fit Analysis", + "resumeQuestions": "Resume-Based Questions", + "projectFollowUps": "Project Follow-Ups", + "skillGaps": "Skill Gaps", + "talkingPoints": "Talking Points" + } } } diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index bcda907a6..7cee55bb9 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -176,7 +176,8 @@ "resume": "CURRÍCULUM", "coverLetter": "CARTA", "outreach": "MENSAJE", - "jdMatch": "COINCIDENCIA JD" + "jdMatch": "COINCIDENCIA JD", + "interviewPrep": "PREPARACION" }, "footer": { "moduleLabel": "Módulo del constructor", @@ -195,7 +196,9 @@ "outreachDescription": "Genera un mensaje de contacto breve para LinkedIn o email según tu currículum y la descripción del puesto.", "generateButton": "Generar {title}", "coverLetterFooter": "Consejo: adapta primero para mejores resultados.", - "outreachFooter": "Consejo: manténlo breve y personalizado." + "outreachFooter": "Consejo: manténlo breve y personalizado.", + "interviewPrepDescription": "Genera preparacion para entrevistas segun tu curriculum adaptado y la descripcion del puesto.", + "interviewPrepFooter": "Consejo: usalo despues de adaptar el curriculum para preparar respuestas veraces basadas en evidencia." }, "regenerateDialog": { "title": "¿Regenerar {title}?", @@ -444,7 +447,8 @@ "editorPanel": "Panel de edición", "coverLetterEditor": "Editor de carta", "outreachEditor": "Editor de mensaje", - "jdMatchAnalysis": "Análisis de coincidencia JD" + "jdMatchAnalysis": "Análisis de coincidencia JD", + "interviewPrep": "Preparacion de entrevista" }, "alerts": { "saveNotAvailable": "Guardar solo está disponible al editar un currículum existente.", @@ -461,7 +465,8 @@ "coverLetterDownloadFailed": "No se pudo descargar la carta de presentación: {error}", "outreachSaveFailed": "No se pudo guardar el mensaje de contacto. Por favor, inténtalo de nuevo.", "coverLetterGenerateFailed": "No se pudo generar la carta de presentación: {error}", - "outreachGenerateFailed": "No se pudo generar el mensaje de contacto: {error}" + "outreachGenerateFailed": "No se pudo generar el mensaje de contacto: {error}", + "interviewPrepGenerateFailed": "No se pudo generar la preparacion de entrevista: {error}" } }, "enrichment": { @@ -705,7 +710,11 @@ "customPromptLabel": "Prompt personalizado (opcional)", "customPromptHelp": "Debe incluir estos marcadores (cada uno entre llaves): job_description, resume_data, output_language. Déjalo en blanco para usar el prompt por defecto.", "customPromptResetButton": "Restablecer al predeterminado", - "customPromptErrorMissing": "Faltan marcadores requeridos: {missing}" + "customPromptErrorMissing": "Faltan marcadores requeridos: {missing}", + "interviewPrep": { + "label": "Preparacion de entrevista", + "description": "Genera automaticamente preparacion de entrevista junto con los curriculums adaptados guardados" + } }, "promptSettings": { "title": "Configuración de Prompts", @@ -1006,5 +1015,24 @@ "moveFailed": "No se pudo mover la candidatura.", "deleteFailed": "No se pudieron eliminar las candidaturas." } + }, + "interviewPrep": { + "title": "Preparacion de entrevista", + "regenerate": "Regenerar", + "unavailableTitle": "Preparacion no disponible", + "loadingContextDescription": "Comprobando el contexto del puesto para este curriculum adaptado antes de generar.", + "missingContextDescription": "Este curriculum adaptado no tiene el contexto del puesto guardado que se necesita para generar preparacion de entrevista. Vuelve a adaptar el curriculum con una descripcion del puesto.", + "saveRequiredDescription": "Guarda este curriculum adaptado antes de generar preparacion de entrevista.", + "focusArea": "Area de enfoque", + "suggestedAnswerPoints": "Puntos sugeridos para responder", + "whyItMatters": "Por que importa", + "preparationSuggestion": "Sugerencia de preparacion", + "sections": { + "roleFit": "Analisis de encaje", + "resumeQuestions": "Preguntas basadas en el curriculum", + "projectFollowUps": "Seguimiento de proyectos", + "skillGaps": "Brechas de habilidades", + "talkingPoints": "Puntos clave" + } } } diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index 53ae18c0a..f05b4f63f 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -176,7 +176,8 @@ "resume": "履歴書", "coverLetter": "カバーレター", "outreach": "連絡メール", - "jdMatch": "JD一致" + "jdMatch": "JD一致", + "interviewPrep": "面接準備" }, "footer": { "moduleLabel": "履歴書ビルダーモジュール", @@ -195,7 +196,9 @@ "outreachDescription": "履歴書と求人内容に基づいて、LinkedIn またはメール向けの外連絡メッセージを生成します。", "generateButton": "{title}を生成", "coverLetterFooter": "ヒント:先にカスタマイズすると精度が上がります。", - "outreachFooter": "ヒント:短く、個別性を入れましょう。" + "outreachFooter": "ヒント:短く、個別性を入れましょう。", + "interviewPrepDescription": "カスタマイズ済み履歴書と求人内容に基づいて、職種別の面接準備を生成します。", + "interviewPrepFooter": "ヒント:カスタマイズ後に、履歴書の根拠に基づく正確な回答を準備しましょう。" }, "regenerateDialog": { "title": "{title}を再生成しますか?", @@ -444,7 +447,8 @@ "editorPanel": "編集パネル", "coverLetterEditor": "カバーレター編集", "outreachEditor": "連絡メール編集", - "jdMatchAnalysis": "JD一致分析" + "jdMatchAnalysis": "JD一致分析", + "interviewPrep": "面接準備" }, "alerts": { "saveNotAvailable": "既存の履歴書を編集中のみ保存できます。", @@ -461,7 +465,8 @@ "coverLetterDownloadFailed": "カバーレターのダウンロードに失敗しました: {error}", "outreachSaveFailed": "連絡メールの保存に失敗しました。もう一度お試しください。", "coverLetterGenerateFailed": "カバーレターの生成に失敗しました: {error}", - "outreachGenerateFailed": "連絡メールの生成に失敗しました: {error}" + "outreachGenerateFailed": "連絡メールの生成に失敗しました: {error}", + "interviewPrepGenerateFailed": "面接準備の生成に失敗しました: {error}" } }, "enrichment": { @@ -705,7 +710,11 @@ "customPromptLabel": "カスタムプロンプト(任意)", "customPromptHelp": "次のプレースホルダーを中括弧で囲んで含めてください:job_description, resume_data, output_language。空欄の場合は既定のプロンプトを使用します。", "customPromptResetButton": "デフォルトにリセット", - "customPromptErrorMissing": "必須のプレースホルダーが不足しています:{missing}" + "customPromptErrorMissing": "必須のプレースホルダーが不足しています:{missing}", + "interviewPrep": { + "label": "面接準備", + "description": "保存済みのカスタマイズ履歴書と一緒に面接準備を自動生成します" + } }, "promptSettings": { "title": "プロンプト設定", @@ -1006,5 +1015,24 @@ "moveFailed": "応募を移動できませんでした。", "deleteFailed": "応募を削除できませんでした。" } + }, + "interviewPrep": { + "title": "面接準備", + "regenerate": "再生成", + "unavailableTitle": "面接準備は利用できません", + "loadingContextDescription": "生成前に、このカスタマイズ済み履歴書の求人コンテキストを確認しています。", + "missingContextDescription": "このカスタマイズ済み履歴書には、面接準備の生成に必要な保存済み求人コンテキストがありません。求人内容で履歴書を再度カスタマイズしてください。", + "saveRequiredDescription": "面接準備を生成する前に、このカスタマイズ済み履歴書を保存してください。", + "focusArea": "重点領域", + "suggestedAnswerPoints": "回答ポイント", + "whyItMatters": "重要な理由", + "preparationSuggestion": "準備の提案", + "sections": { + "roleFit": "職務適合分析", + "resumeQuestions": "履歴書ベースの質問", + "projectFollowUps": "プロジェクト深掘り質問", + "skillGaps": "スキルギャップ", + "talkingPoints": "話すべき要点" + } } } diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index 1055b8a53..7ac5ee142 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -176,7 +176,8 @@ "resume": "CURRÍCULO", "coverLetter": "CARTA DE APRESENTAÇÃO", "outreach": "MENSAGEM DE CONTATO", - "jdMatch": "ANÁLISE DE VAGA" + "jdMatch": "ANÁLISE DE VAGA", + "interviewPrep": "PREPARACAO" }, "footer": { "moduleLabel": "Módulo de Criação de Currículo", @@ -195,7 +196,9 @@ "outreachDescription": "Gere uma mensagem de contato concisa para LinkedIn ou email com base no seu currículo e descrição da vaga.", "generateButton": "Gerar {title}", "coverLetterFooter": "Dica: personalize primeiro para melhores resultados.", - "outreachFooter": "Dica: mantenha curta e personalizada." + "outreachFooter": "Dica: mantenha curta e personalizada.", + "interviewPrepDescription": "Gere preparacao para entrevista com base no curriculo adaptado e na descricao da vaga.", + "interviewPrepFooter": "Dica: use depois de adaptar o curriculo para preparar respostas verdadeiras e baseadas em evidencias." }, "regenerateDialog": { "title": "Regenerar {title}?", @@ -444,7 +447,8 @@ "editorPanel": "Painel do Editor", "coverLetterEditor": "Editor de Carta de Apresentação", "outreachEditor": "Editor de Mensagem de Contato", - "jdMatchAnalysis": "Análise de Correspondência de Vaga" + "jdMatchAnalysis": "Análise de Correspondência de Vaga", + "interviewPrep": "Preparacao para entrevista" }, "alerts": { "saveNotAvailable": "Salvar só está disponível ao editar um currículo existente.", @@ -461,7 +465,8 @@ "coverLetterDownloadFailed": "Falha ao baixar carta de apresentação: {error}", "outreachSaveFailed": "Falha ao salvar mensagem de contato. Por favor, tente novamente.", "coverLetterGenerateFailed": "Falha ao gerar carta de apresentação: {error}", - "outreachGenerateFailed": "Falha ao gerar mensagem de contato: {error}" + "outreachGenerateFailed": "Falha ao gerar mensagem de contato: {error}", + "interviewPrepGenerateFailed": "Falha ao gerar preparacao para entrevista: {error}" } }, "enrichment": { @@ -705,7 +710,11 @@ "customPromptLabel": "Prompt personalizado (opcional)", "customPromptHelp": "Deve incluir estes marcadores (cada um entre chaves): job_description, resume_data, output_language. Deixe em branco para usar o prompt padrão.", "customPromptResetButton": "Restaurar padrão", - "customPromptErrorMissing": "Marcadores obrigatórios ausentes: {missing}" + "customPromptErrorMissing": "Marcadores obrigatórios ausentes: {missing}", + "interviewPrep": { + "label": "Preparacao para entrevista", + "description": "Gerar automaticamente preparacao para entrevista junto com curriculos adaptados salvos" + } }, "promptSettings": { "title": "Configurações de Prompt", @@ -1006,5 +1015,24 @@ "moveFailed": "Não foi possível mover a candidatura.", "deleteFailed": "Não foi possível excluir a(s) candidatura(s)." } + }, + "interviewPrep": { + "title": "Preparacao para entrevista", + "regenerate": "Gerar novamente", + "unavailableTitle": "Preparacao indisponivel", + "loadingContextDescription": "Verificando o contexto da vaga para este curriculo adaptado antes de gerar.", + "missingContextDescription": "Este curriculo adaptado nao tem o contexto da vaga salvo necessario para gerar preparacao para entrevista. Adapte o curriculo novamente com uma descricao da vaga.", + "saveRequiredDescription": "Salve este curriculo adaptado antes de gerar preparacao para entrevista.", + "focusArea": "Area de foco", + "suggestedAnswerPoints": "Pontos sugeridos para resposta", + "whyItMatters": "Por que importa", + "preparationSuggestion": "Sugestao de preparacao", + "sections": { + "roleFit": "Analise de adequacao", + "resumeQuestions": "Perguntas baseadas no curriculo", + "projectFollowUps": "Perguntas sobre projetos", + "skillGaps": "Lacunas de habilidades", + "talkingPoints": "Pontos de conversa" + } } } diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index 0e8d2d748..5867b79f9 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -176,7 +176,8 @@ "resume": "简历", "coverLetter": "求职信", "outreach": "联系邮件", - "jdMatch": "JD 匹配" + "jdMatch": "JD 匹配", + "interviewPrep": "面试准备" }, "footer": { "moduleLabel": "简历编辑模块", @@ -195,7 +196,9 @@ "outreachDescription": "基于你的简历与职位描述生成用于 LinkedIn 或邮件的外联消息。", "generateButton": "生成 {title}", "coverLetterFooter": "提示:先完成简历定制效果更佳。", - "outreachFooter": "提示:尽量简短并加入个性化细节。" + "outreachFooter": "提示:尽量简短并加入个性化细节。", + "interviewPrepDescription": "基于已定制简历和职位描述生成针对性的面试准备内容。", + "interviewPrepFooter": "提示:定制简历后使用,准备真实且基于简历证据的回答。" }, "regenerateDialog": { "title": "重新生成 {title}?", @@ -444,7 +447,8 @@ "editorPanel": "编辑面板", "coverLetterEditor": "求职信编辑", "outreachEditor": "联系邮件编辑", - "jdMatchAnalysis": "JD 匹配分析" + "jdMatchAnalysis": "JD 匹配分析", + "interviewPrep": "面试准备" }, "alerts": { "saveNotAvailable": "仅在编辑现有简历时可保存。", @@ -461,7 +465,8 @@ "coverLetterDownloadFailed": "下载求职信失败:{error}", "outreachSaveFailed": "保存联系邮件失败,请重试。", "coverLetterGenerateFailed": "生成求职信失败:{error}", - "outreachGenerateFailed": "生成联系邮件失败:{error}" + "outreachGenerateFailed": "生成联系邮件失败:{error}", + "interviewPrepGenerateFailed": "生成面试准备失败:{error}" } }, "enrichment": { @@ -705,7 +710,11 @@ "customPromptLabel": "自定义提示词(可选)", "customPromptHelp": "必须包含以下占位符(各自使用大括号括起来):job_description、resume_data、output_language。留空则使用默认提示词。", "customPromptResetButton": "恢复默认", - "customPromptErrorMissing": "缺少必需的占位符:{missing}" + "customPromptErrorMissing": "缺少必需的占位符:{missing}", + "interviewPrep": { + "label": "面试准备", + "description": "保存定制简历时自动生成面试准备内容" + } }, "promptSettings": { "title": "提示词设置", @@ -1006,5 +1015,24 @@ "moveFailed": "无法移动该申请。", "deleteFailed": "无法删除该申请。" } + }, + "interviewPrep": { + "title": "面试准备", + "regenerate": "重新生成", + "unavailableTitle": "面试准备不可用", + "loadingContextDescription": "正在检查这份定制简历的职位上下文,然后才能生成面试准备。", + "missingContextDescription": "这份定制简历缺少生成面试准备所需的已保存职位上下文。请使用职位描述重新定制简历后再使用此流程。", + "saveRequiredDescription": "请先保存这份定制简历,然后再生成面试准备。", + "focusArea": "关注点", + "suggestedAnswerPoints": "建议回答要点", + "whyItMatters": "为什么重要", + "preparationSuggestion": "准备建议", + "sections": { + "roleFit": "岗位匹配分析", + "resumeQuestions": "基于简历的问题", + "projectFollowUps": "项目追问", + "skillGaps": "技能差距", + "talkingPoints": "沟通要点" + } } } diff --git a/apps/frontend/tests/additional-info-newlines.test.tsx b/apps/frontend/tests/additional-info-newlines.test.tsx index 2b853e758..81fbf2bd7 100644 --- a/apps/frontend/tests/additional-info-newlines.test.tsx +++ b/apps/frontend/tests/additional-info-newlines.test.tsx @@ -2,10 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import { AdditionalForm } from '@/components/builder/forms/additional-form'; import { ResumeSingleColumn } from '@/components/resume/resume-single-column'; -import type { - AdditionalInfo, - ResumeData, -} from '@/components/dashboard/resume-component'; +import type { AdditionalInfo, ResumeData } from '@/components/dashboard/resume-component'; // Components call useTranslations(); return the key so we can match deterministically. vi.mock('@/lib/i18n', () => ({ @@ -40,11 +37,7 @@ describe('AdditionalForm newline handling (issue #763)', () => { const textarea = screen.getByLabelText('resume.additional.technicalSkills'); fireEvent.change(textarea, { target: { value: 'React\n\nTypeScript' } }); - expect(onChange.mock.calls[0][0].technicalSkills).toEqual([ - 'React', - '', - 'TypeScript', - ]); + expect(onChange.mock.calls[0][0].technicalSkills).toEqual(['React', '', 'TypeScript']); }); }); diff --git a/apps/frontend/tests/api-client.test.ts b/apps/frontend/tests/api-client.test.ts index f641fa1a0..f8df59a9e 100644 --- a/apps/frontend/tests/api-client.test.ts +++ b/apps/frontend/tests/api-client.test.ts @@ -74,14 +74,15 @@ describe('api client', () => { it('aborts after the timeout and reports a timeout error', async () => { vi.useFakeTimers(); try { - fetchMock.mockImplementation((_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - init.signal?.addEventListener('abort', () => { - const e = new Error('The operation was aborted'); - e.name = 'AbortError'; - reject(e); - }); - }) + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + const e = new Error('The operation was aborted'); + e.name = 'AbortError'; + reject(e); + }); + }) ); const promise = apiFetch('/slow', undefined, 5000); const expectation = expect(promise).rejects.toThrow(/timed out/i); diff --git a/apps/frontend/tests/api-config.test.ts b/apps/frontend/tests/api-config.test.ts index 776dc66e0..5ea74ef1b 100644 --- a/apps/frontend/tests/api-config.test.ts +++ b/apps/frontend/tests/api-config.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FeaturePromptsError, PROVIDER_INFO, + fetchFeatureConfig, + updateFeatureConfig, updateFeaturePrompts, type LLMProvider, } from '@/lib/api/config'; @@ -83,7 +85,9 @@ describe('updateFeaturePrompts error mapping', () => { }); it('throws a generic Error with the string detail on other failures', async () => { - fetchMock.mockResolvedValue(new Response(JSON.stringify({ detail: 'server boom' }), { status: 500 })); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ detail: 'server boom' }), { status: 500 }) + ); await expect(updateFeaturePrompts({ cover_letter_prompt: 'x' })).rejects.toThrow('server boom'); }); @@ -98,3 +102,41 @@ describe('updateFeaturePrompts error mapping', () => { await expect(updateFeaturePrompts({ cover_letter_prompt: 'a' })).resolves.toEqual(prompts); }); }); + +describe('feature config API', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('parses enable_interview_prep from GET /config/features', async () => { + const config = { + enable_cover_letter: true, + enable_outreach_message: false, + enable_interview_prep: true, + }; + fetchMock.mockResolvedValue(new Response(JSON.stringify(config), { status: 200 })); + + await expect(fetchFeatureConfig()).resolves.toEqual(config); + }); + + it('sends enable_interview_prep in PUT /config/features', async () => { + const config = { + enable_cover_letter: false, + enable_outreach_message: false, + enable_interview_prep: true, + }; + fetchMock.mockResolvedValue(new Response(JSON.stringify(config), { status: 200 })); + + await expect(updateFeatureConfig({ enable_interview_prep: true })).resolves.toEqual(config); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body as string)).toEqual({ enable_interview_prep: true }); + }); +}); diff --git a/apps/frontend/tests/api-resume.test.ts b/apps/frontend/tests/api-resume.test.ts new file mode 100644 index 000000000..8845af443 --- /dev/null +++ b/apps/frontend/tests/api-resume.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateInterviewPrep } from '@/lib/api/resume'; + +const interviewPrep = { + role_fit_analysis: ['Backend API experience fits the role.'], + resume_questions: [ + { + question: 'How did you build the API?', + focus_area: 'Backend architecture', + suggested_answer_points: ['Discuss resume-grounded API work.'], + }, + ], + project_follow_ups: [], + skill_gaps: [ + { + skill: 'Kubernetes', + why_it_matters: 'The JD mentions deployments.', + preparation_suggestion: 'Review basics without claiming experience.', + }, + ], + talking_points: ['Connect API work to the role.'], +}; + +describe('resume API', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('generates interview prep and parses the structured payload', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + interview_prep: interviewPrep, + message: 'Interview preparation generated successfully', + }), + { status: 200 } + ) + ); + + await expect(generateInterviewPrep('res 123')).resolves.toEqual(interviewPrep); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/v1/resumes/res%20123/generate-interview-prep'); + expect(init.method).toBe('POST'); + }); + + it('throws a useful error when interview prep generation fails', async () => { + fetchMock.mockResolvedValue(new Response('server boom', { status: 500 })); + + await expect(generateInterviewPrep('res-123')).rejects.toThrow( + 'Failed to generate interview preparation' + ); + }); +}); diff --git a/apps/frontend/tests/i18n-locale-parity.test.ts b/apps/frontend/tests/i18n-locale-parity.test.ts index 1cead6752..1bcc6643c 100644 --- a/apps/frontend/tests/i18n-locale-parity.test.ts +++ b/apps/frontend/tests/i18n-locale-parity.test.ts @@ -30,7 +30,11 @@ function nodeKind(v: unknown): string { return typeof v; // 'string' | 'number' | 'boolean' } -function keyKinds(obj: unknown, prefix = '', out: Map = new Map()): Map { +function keyKinds( + obj: unknown, + prefix = '', + out: Map = new Map() +): Map { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return out; for (const [key, val] of Object.entries(obj as Record)) { const path = prefix ? `${prefix}.${key}` : key; @@ -62,7 +66,9 @@ describe('i18n locale parity (guards the next build break)', () => { expect(missing, `${name}.json is MISSING keys present in en.json`).toEqual([]); expect(mismatched, `${name}.json has keys whose SHAPE differs from en.json`).toEqual([]); if (extra.length > 0) { - console.warn(`locale-parity: ${name}.json has extra keys not in en.json (non-fatal): ${extra.join(', ')}`); + console.warn( + `locale-parity: ${name}.json has extra keys not in en.json (non-fatal): ${extra.join(', ')}` + ); } }); diff --git a/apps/frontend/tests/interview-prep-view.test.tsx b/apps/frontend/tests/interview-prep-view.test.tsx new file mode 100644 index 000000000..08ee87e11 --- /dev/null +++ b/apps/frontend/tests/interview-prep-view.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { InterviewPrepView } from '@/components/builder/interview-prep-view'; +import type { InterviewPrepData } from '@/components/common/resume_previewer_context'; + +vi.mock('@/lib/i18n', () => ({ + useTranslations: () => ({ + t: (key: string, params?: Record) => + params?.title ? `${key} ${params.title}` : key, + }), +})); + +const interviewPrep: InterviewPrepData = { + role_fit_analysis: ['Backend API experience fits the role.'], + resume_questions: [ + { + question: 'How did you build the API?', + focus_area: 'Backend architecture', + suggested_answer_points: ['Discuss resume-grounded API work.'], + }, + ], + project_follow_ups: [ + { + question: 'What tradeoffs did you make in the matcher project?', + focus_area: 'Project tradeoffs', + suggested_answer_points: ['Explain real implementation choices.'], + }, + ], + skill_gaps: [ + { + skill: 'Kubernetes', + why_it_matters: 'The JD mentions deployments.', + preparation_suggestion: 'Review basics without claiming experience.', + }, + ], + talking_points: ['Connect API work to the role.'], +}; + +describe('InterviewPrepView', () => { + it('renders an empty state and calls onGenerate', () => { + const onGenerate = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole('button', { name: /builder\.generatePrompt\.generateButton/ }) + ); + + expect(onGenerate).toHaveBeenCalledTimes(1); + }); + + it('renders loading and error states in the empty view', () => { + render( + + ); + + expect(screen.getByText('Generation failed')).toBeInTheDocument(); + expect(screen.getByText('common.generating')).toBeInTheDocument(); + }); + + it('disables generation when job context is unavailable', () => { + const onGenerate = vi.fn(); + render( + + ); + + expect(screen.getByText('interviewPrep.unavailableTitle')).toBeInTheDocument(); + expect(screen.getByText('No saved job context')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders generated interview prep sections', () => { + render( + + ); + + expect(screen.getByText('Backend API experience fits the role.')).toBeInTheDocument(); + expect(screen.getByText('How did you build the API?')).toBeInTheDocument(); + expect( + screen.getByText('What tradeoffs did you make in the matcher project?') + ).toBeInTheDocument(); + expect(screen.getByText('Kubernetes')).toBeInTheDocument(); + expect(screen.getByText('Connect API work to the role.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/tests/section-helpers.test.ts b/apps/frontend/tests/section-helpers.test.ts index 182453d74..d14c468b0 100644 --- a/apps/frontend/tests/section-helpers.test.ts +++ b/apps/frontend/tests/section-helpers.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { - ResumeData, - SectionMeta, -} from '@/components/dashboard/resume-component'; +import type { ResumeData, SectionMeta } from '@/components/dashboard/resume-component'; import { DEFAULT_SECTION_META, createCustomSection, @@ -95,17 +92,26 @@ describe('localizeDefaultSectionMeta', () => { const t = (key: string) => `t:${key}`; it('localizes a default section whose name is still the English default', () => { - const out = localizeDefaultSectionMeta([meta({ id: 'summary', displayName: 'Summary', isDefault: true })], t); + const out = localizeDefaultSectionMeta( + [meta({ id: 'summary', displayName: 'Summary', isDefault: true })], + t + ); expect(out[0].displayName).toBe('t:resume.sections.summary'); }); it('leaves a renamed default section untouched', () => { - const out = localizeDefaultSectionMeta([meta({ id: 'summary', displayName: 'My Story', isDefault: true })], t); + const out = localizeDefaultSectionMeta( + [meta({ id: 'summary', displayName: 'My Story', isDefault: true })], + t + ); expect(out[0].displayName).toBe('My Story'); }); it('leaves non-default (custom) sections untouched', () => { - const out = localizeDefaultSectionMeta([meta({ id: 'custom_1', displayName: 'Summary', isDefault: false })], t); + const out = localizeDefaultSectionMeta( + [meta({ id: 'custom_1', displayName: 'Summary', isDefault: false })], + t + ); expect(out[0].displayName).toBe('Summary'); }); }); diff --git a/docs/agent/apis/api-flow-maps.md b/docs/agent/apis/api-flow-maps.md index dd3955e18..48aefe0c7 100644 --- a/docs/agent/apis/api-flow-maps.md +++ b/docs/agent/apis/api-flow-maps.md @@ -24,9 +24,23 @@ POST /api/v1/resumes/improve ├── improve_resume() → LLM ├── [If enabled] generate_cover_letter() → LLM ├── [If enabled] generate_outreach_message() → LLM +├── [If enabled] generate_interview_prep() → LLM ├── db.create_resume(improved) ├── db.create_improvement() -└── Return {data, cover_letter, outreach_message} +└── Return {data, cover_letter, outreach_message, interview_prep} +``` + +## Interview Prep Generation + +``` +POST /api/v1/resumes/{id}/generate-interview-prep +├── Require tailored resume (parent_id) +├── Fetch improvement record and associated job description +├── Require processed resume data +├── generate_interview_prep() → LLM JSON +├── Validate InterviewPrepData +├── Save resumes.interview_prep as serialized JSON TEXT +└── Return {interview_prep, message} ``` ## PDF Generation diff --git a/docs/agent/apis/backend-requirements.md b/docs/agent/apis/backend-requirements.md index f81efc722..39cc356a1 100644 --- a/docs/agent/apis/backend-requirements.md +++ b/docs/agent/apis/backend-requirements.md @@ -27,8 +27,8 @@ GET /status → {status, llm_configured, llm_healthy, database_stats} GET /config/llm-api-key → {provider, model, api_key(masked), api_base?} PUT /config/llm-api-key ← {provider, model, api_key, api_base?} POST /config/llm-test → {healthy, provider, model} -GET /config/features → {enable_cover_letter, enable_outreach_message} -PUT /config/features ← {enable_cover_letter?, enable_outreach_message?} +GET /config/features → {enable_cover_letter, enable_outreach_message, enable_interview_prep} +PUT /config/features ← {enable_cover_letter?, enable_outreach_message?, enable_interview_prep?} ``` ### Resumes @@ -38,11 +38,13 @@ POST /resumes/upload ← multipart/form-data {file} → {resume_id} GET /resumes?resume_id= → Resume object GET /resumes/list → [{resume_id, filename, is_master, created_at}] -PATCH /resumes/{id} ← {processed_data, cover_letter?, outreach_message?} +PATCH /resumes/{id} ← ResumeData DELETE /resumes/{id} → {message} GET /resumes/{id}/pdf → application/pdf POST /resumes/improve ← {resume_id, job_id} - → {data, cover_letter?, outreach_message?} + → {data, cover_letter?, outreach_message?, interview_prep?} +POST /resumes/{id}/generate-interview-prep + → {interview_prep, message} ``` ### Jobs @@ -70,7 +72,8 @@ GET /jobs/{id} → {job_id, content, created_at} "is_master": true, "processing_status": "ready|processing|failed", "cover_letter": "text or null", - "outreach_message": "text or null" + "outreach_message": "text or null", + "interview_prep": "structured object or null" } ``` diff --git a/docs/agent/apis/front-end-apis.md b/docs/agent/apis/front-end-apis.md index 89b0d36ec..fb1172b6a 100644 --- a/docs/agent/apis/front-end-apis.md +++ b/docs/agent/apis/front-end-apis.md @@ -38,6 +38,9 @@ downloadCoverLetterPdf(resumeId: string, pageSize?: string) → Blob // Content updates updateCoverLetter(resumeId: string, content: string) → void updateOutreachMessage(resumeId: string, content: string) → void + +// On-demand generated content +generateInterviewPrep(resumeId: string) → InterviewPrepData ``` ## Resume Wizard (`lib/api/resume-wizard.ts`) diff --git a/scripts/check_locale_parity.py b/scripts/check_locale_parity.py index afd0b6d52..a9c1ad1bc 100755 --- a/scripts/check_locale_parity.py +++ b/scripts/check_locale_parity.py @@ -3,14 +3,14 @@ The frontend declares ``type Messages = typeof en`` and ``const allMessages: Record``, so a locale JSON that is -*missing* a key present in ``en.json`` — or that has a key of a *different shape* -(an object where ``en`` has a string, or vice versa) — makes ``tsc`` / +*missing* a key present in ``en.json`` - or that has a key of a *different shape* +(an object where ``en`` has a string, or vice versa) - makes ``tsc`` / ``next build`` fail. That is exactly the break that shipped to ``main`` and only -surfaced (post-merge) inside the Docker publish job — the incident that +surfaced (post-merge) inside the Docker publish job - the incident that motivated the testing work. -This check reproduces and prevents it with **pure stdlib** — no Node, npm, or -nvm — so it runs fast inside the local pre-push hook regardless of shell setup. +This check reproduces and prevents it with **pure stdlib** - no Node, npm, or +nvm - so it runs fast inside the local pre-push hook regardless of shell setup. Exit code: 0 = all locales match; 1 = at least one locale is missing keys, has a key whose shape (leaf vs. object) differs from ``en``, or is not valid JSON. @@ -49,9 +49,9 @@ def _node_kind(value: Any) -> str: def key_kinds(obj: Any, prefix: str = "") -> dict[str, str]: - """Map every dotted key path to its JSON *kind* (object/array/string/number/…). + """Map every dotted key path to its JSON kind. - Comparing *kinds* — not just key presence — is what catches a locale whose + Comparing kinds - not just key presence - is what catches a locale whose ``a.b`` is an object (or a number, or an array) where ``en`` has a string: the key path is still present, so a presence-only check passes, yet it still breaks ``next build`` because the locale is no longer assignable to @@ -89,7 +89,7 @@ def main(argv: list[str]) -> int: try: reference_kinds = key_kinds(_load(reference_file)) except ValueError as exc: - print(f"locale-parity: ✗ {exc}", file=sys.stderr) + print(f"locale-parity: error: {exc}", file=sys.stderr) return 1 reference_paths = set(reference_kinds) @@ -102,7 +102,7 @@ def main(argv: list[str]) -> int: locale_kinds = key_kinds(_load(path)) except ValueError as exc: failed = True - print(f"locale-parity: ✗ {exc}", file=sys.stderr) + print(f"locale-parity: error: {exc}", file=sys.stderr) continue locale_paths = set(locale_kinds) @@ -116,14 +116,14 @@ def main(argv: list[str]) -> int: if missing: failed = True - print(f"locale-parity: ✗ {path.name} is MISSING keys from {REFERENCE}:", file=sys.stderr) + print(f"locale-parity: error: {path.name} is MISSING keys from {REFERENCE}:", file=sys.stderr) for key in sorted(missing): print(f" missing: {key}", file=sys.stderr) if mismatched: failed = True print( - f"locale-parity: ✗ {path.name} has keys whose JSON type differs " - f"from {REFERENCE} — this breaks `next build`:", + f"locale-parity: error: {path.name} has keys whose JSON type differs " + f"from {REFERENCE}; this breaks `next build`:", file=sys.stderr, ) for key in sorted(mismatched): @@ -133,20 +133,23 @@ def main(argv: list[str]) -> int: file=sys.stderr, ) if extra: - print(f"locale-parity: ⚠ {path.name} has extra keys not in {REFERENCE} (non-fatal):", file=sys.stderr) + print( + f"locale-parity: warning: {path.name} has extra keys not in {REFERENCE} (non-fatal):", + file=sys.stderr, + ) for key in sorted(extra): print(f" extra: {key}", file=sys.stderr) if failed: print( - "locale-parity: locale files are out of sync with en.json — this would " + "locale-parity: locale files are out of sync with en.json; this would " "break `next build` (type Messages = typeof en). Fix the missing / " "mismatched keys.", file=sys.stderr, ) return 1 - print("locale-parity: ✓ all locale files match en.json") + print("locale-parity: ok all locale files match en.json") return 0 From 8387429162367f9f428d33f9d4d6bc7a4321809d Mon Sep 17 00:00:00 2001 From: GwIhViEte <1007435607@qq.com> Date: Thu, 2 Jul 2026 20:58:55 +0800 Subject: [PATCH 2/4] Address interview prep PR review comments --- apps/backend/app/database.py | 4 +- apps/backend/app/prompts/templates.py | 1 + apps/backend/app/services/interview_prep.py | 94 ++++++++++++++++++- .../tests/unit/test_interview_prep_service.py | 25 +++++ .../tests/unit/test_prompt_guardrails.py | 1 + .../builder/interview-prep-view.tsx | 13 +-- apps/frontend/messages/es.json | 34 +++---- apps/frontend/messages/pt-BR.json | 32 +++---- .../tests/interview-prep-view.test.tsx | 15 +++ docs/agent/apis/backend-requirements.md | 17 ++++ 10 files changed, 190 insertions(+), 46 deletions(-) diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index 26ac9f41b..4b237a8d0 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -190,9 +190,9 @@ async def create_resume( processing_status: str = "pending", cover_letter: str | None = None, outreach_message: str | None = None, - interview_prep: str | None = None, title: str | None = None, original_markdown: str | None = None, + interview_prep: str | None = None, ) -> dict[str, Any]: """Create a new resume entry. @@ -251,9 +251,9 @@ async def create_resume_atomic_master( processing_status: str = "pending", cover_letter: str | None = None, outreach_message: str | None = None, - interview_prep: str | None = None, original_markdown: str | None = None, title: str | None = None, + interview_prep: str | None = None, ) -> dict[str, Any]: """Create a new resume with atomic master assignment. diff --git a/apps/backend/app/prompts/templates.py b/apps/backend/app/prompts/templates.py index 5e7ac7aec..ee0a87bc2 100644 --- a/apps/backend/app/prompts/templates.py +++ b/apps/backend/app/prompts/templates.py @@ -399,6 +399,7 @@ def _build_truthfulness_rules(rule_7: str) -> str: INTERVIEW_PREP_PROMPT = """Generate structured interview preparation for this tailored resume and job. IMPORTANT: Write in {output_language}. +Do NOT translate JSON property names. Keep every JSON key exactly as shown in the schema; translate only string values. Job Description: {job_description} diff --git a/apps/backend/app/services/interview_prep.py b/apps/backend/app/services/interview_prep.py index 6a4819281..635359f75 100644 --- a/apps/backend/app/services/interview_prep.py +++ b/apps/backend/app/services/interview_prep.py @@ -8,6 +8,91 @@ from app.schemas import InterviewPrepData +_JOB_DESCRIPTION_PROMPT_CHAR_LIMIT = 12_000 +_RESUME_DATA_PROMPT_CHAR_LIMIT = 30_000 +_TRUNCATION_NOTICE = ( + "[Content truncated for prompt length. Use only the visible evidence; " + "do not infer or invent omitted details.]" +) + + +def _truncate_text_for_prompt(value: str, max_chars: int) -> str: + """Bound unstructured prompt input while making omissions explicit.""" + if len(value) <= max_chars: + return value + return f"{value[:max_chars].rstrip()}\n\n{_TRUNCATION_NOTICE}" + + +def _truncate_json_value( + value: Any, + *, + max_string_chars: int, + max_list_items: int, +) -> Any: + if isinstance(value, str): + return _truncate_text_for_prompt(value, max_string_chars) + if isinstance(value, list): + truncated = [ + _truncate_json_value( + item, + max_string_chars=max_string_chars, + max_list_items=max_list_items, + ) + for item in value[:max_list_items] + ] + if len(value) > max_list_items: + truncated.append( + { + "_prompt_truncation_notice": ( + f"{len(value) - max_list_items} additional items omitted. " + "Do not infer omitted details." + ) + } + ) + return truncated + if isinstance(value, dict): + return { + key: _truncate_json_value( + item, + max_string_chars=max_string_chars, + max_list_items=max_list_items, + ) + for key, item in value.items() + } + return value + + +def _serialize_resume_data_for_prompt(resume_data: dict[str, Any]) -> str: + resume_json = json.dumps(resume_data, ensure_ascii=False) + if len(resume_json) <= _RESUME_DATA_PROMPT_CHAR_LIMIT: + return resume_json + + for max_string_chars, max_list_items in ((2_000, 30), (1_000, 20), (500, 10)): + bounded = _truncate_json_value( + resume_data, + max_string_chars=max_string_chars, + max_list_items=max_list_items, + ) + bounded_json = json.dumps(bounded, ensure_ascii=False) + if len(bounded_json) <= _RESUME_DATA_PROMPT_CHAR_LIMIT: + return bounded_json + + compact_snapshot = json.dumps( + _truncate_json_value(resume_data, max_string_chars=250, max_list_items=5), + ensure_ascii=False, + ) + return json.dumps( + { + "_prompt_truncation_notice": _TRUNCATION_NOTICE, + "limited_resume_snapshot": _truncate_text_for_prompt( + compact_snapshot, + _RESUME_DATA_PROMPT_CHAR_LIMIT - 500, + ), + }, + ensure_ascii=False, + ) + + async def generate_interview_prep( resume_data: dict[str, Any], job_description: str, @@ -15,8 +100,11 @@ async def generate_interview_prep( ) -> InterviewPrepData: """Generate structured interview preparation for a tailored resume.""" prompt = INTERVIEW_PREP_PROMPT.format( - job_description=job_description, - resume_data=json.dumps(resume_data, ensure_ascii=False), + job_description=_truncate_text_for_prompt( + job_description, + _JOB_DESCRIPTION_PROMPT_CHAR_LIMIT, + ), + resume_data=_serialize_resume_data_for_prompt(resume_data), output_language=get_language_name(language), ) @@ -26,7 +114,7 @@ async def generate_interview_prep( "You are a career interview coach. Output truthful, resume-grounded " "interview preparation as JSON only." ), - max_tokens=4096, + max_tokens=8192, schema_type="interview_prep", ) diff --git a/apps/backend/tests/unit/test_interview_prep_service.py b/apps/backend/tests/unit/test_interview_prep_service.py index 7372f3032..c77ba6639 100644 --- a/apps/backend/tests/unit/test_interview_prep_service.py +++ b/apps/backend/tests/unit/test_interview_prep_service.py @@ -50,9 +50,34 @@ async def test_generate_interview_prep_validates_successful_json(): assert result.role_fit_analysis == ["Python API experience is relevant."] mock_complete.assert_awaited_once() + assert mock_complete.await_args.kwargs["max_tokens"] == 8192 assert mock_complete.await_args.kwargs["schema_type"] == "interview_prep" +@pytest.mark.asyncio +async def test_generate_interview_prep_bounds_prompt_inputs(): + with patch( + "app.services.interview_prep.complete_json", + new_callable=AsyncMock, + ) as mock_complete: + mock_complete.return_value = _valid_payload() + + large_resume = { + **SAMPLE_RESUME, + "summary": "Backend engineer " + ("with API delivery evidence. " * 3000), + } + await generate_interview_prep( + large_resume, + "Need FastAPI. " + ("Detailed requirement. " * 1500), + "en", + ) + + prompt = mock_complete.await_args.kwargs["prompt"] + assert len(prompt) < 50_000 + assert "Content truncated for prompt length" in prompt + assert "do not infer or invent omitted details" in prompt + + @pytest.mark.asyncio async def test_generate_interview_prep_rejects_malformed_llm_json(): with patch( diff --git a/apps/backend/tests/unit/test_prompt_guardrails.py b/apps/backend/tests/unit/test_prompt_guardrails.py index 73b208973..c159236a7 100644 --- a/apps/backend/tests/unit/test_prompt_guardrails.py +++ b/apps/backend/tests/unit/test_prompt_guardrails.py @@ -49,5 +49,6 @@ def test_interview_prep_keeps_no_fabrication_guardrails(self): assert "Do NOT invent experience" in INTERVIEW_PREP_PROMPT assert "tools, employers, metrics, certifications, skills" in INTERVIEW_PREP_PROMPT assert "Skill gaps are preparation targets only" in INTERVIEW_PREP_PROMPT + assert "Do NOT translate JSON property names" in INTERVIEW_PREP_PROMPT assert "role_fit_analysis" in INTERVIEW_PREP_PROMPT assert "talking_points" in INTERVIEW_PREP_PROMPT diff --git a/apps/frontend/components/builder/interview-prep-view.tsx b/apps/frontend/components/builder/interview-prep-view.tsx index 77c7705c9..f4ea0993e 100644 --- a/apps/frontend/components/builder/interview-prep-view.tsx +++ b/apps/frontend/components/builder/interview-prep-view.tsx @@ -135,12 +135,7 @@ export function InterviewPrepView({
)} {isTailoredResume && !canGenerate ? ( -
+
@@ -171,10 +166,12 @@ export function InterviewPrepView({

{error}

)} - {isTailoredResume && !canGenerate && unavailableMessage && ( + {isTailoredResume && !canGenerate && (
-

{unavailableMessage}

+

+ {unavailableMessage ?? t('interviewPrep.missingContextDescription')} +

)} diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 7cee55bb9..57eb97036 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -177,7 +177,7 @@ "coverLetter": "CARTA", "outreach": "MENSAJE", "jdMatch": "COINCIDENCIA JD", - "interviewPrep": "PREPARACION" + "interviewPrep": "PREPARACIÓN" }, "footer": { "moduleLabel": "Módulo del constructor", @@ -197,8 +197,8 @@ "generateButton": "Generar {title}", "coverLetterFooter": "Consejo: adapta primero para mejores resultados.", "outreachFooter": "Consejo: manténlo breve y personalizado.", - "interviewPrepDescription": "Genera preparacion para entrevistas segun tu curriculum adaptado y la descripcion del puesto.", - "interviewPrepFooter": "Consejo: usalo despues de adaptar el curriculum para preparar respuestas veraces basadas en evidencia." + "interviewPrepDescription": "Genera preparación para entrevistas según tu currículum adaptado y la descripción del puesto.", + "interviewPrepFooter": "Consejo: úsalo después de adaptar el currículum para preparar respuestas veraces basadas en evidencia." }, "regenerateDialog": { "title": "¿Regenerar {title}?", @@ -448,7 +448,7 @@ "coverLetterEditor": "Editor de carta", "outreachEditor": "Editor de mensaje", "jdMatchAnalysis": "Análisis de coincidencia JD", - "interviewPrep": "Preparacion de entrevista" + "interviewPrep": "Preparación de entrevista" }, "alerts": { "saveNotAvailable": "Guardar solo está disponible al editar un currículum existente.", @@ -466,7 +466,7 @@ "outreachSaveFailed": "No se pudo guardar el mensaje de contacto. Por favor, inténtalo de nuevo.", "coverLetterGenerateFailed": "No se pudo generar la carta de presentación: {error}", "outreachGenerateFailed": "No se pudo generar el mensaje de contacto: {error}", - "interviewPrepGenerateFailed": "No se pudo generar la preparacion de entrevista: {error}" + "interviewPrepGenerateFailed": "No se pudo generar la preparación de entrevista: {error}" } }, "enrichment": { @@ -712,8 +712,8 @@ "customPromptResetButton": "Restablecer al predeterminado", "customPromptErrorMissing": "Faltan marcadores requeridos: {missing}", "interviewPrep": { - "label": "Preparacion de entrevista", - "description": "Genera automaticamente preparacion de entrevista junto con los curriculums adaptados guardados" + "label": "Preparación de entrevista", + "description": "Genera automáticamente preparación de entrevista junto con los currículums adaptados guardados" } }, "promptSettings": { @@ -1017,19 +1017,19 @@ } }, "interviewPrep": { - "title": "Preparacion de entrevista", + "title": "Preparación de entrevista", "regenerate": "Regenerar", - "unavailableTitle": "Preparacion no disponible", - "loadingContextDescription": "Comprobando el contexto del puesto para este curriculum adaptado antes de generar.", - "missingContextDescription": "Este curriculum adaptado no tiene el contexto del puesto guardado que se necesita para generar preparacion de entrevista. Vuelve a adaptar el curriculum con una descripcion del puesto.", - "saveRequiredDescription": "Guarda este curriculum adaptado antes de generar preparacion de entrevista.", - "focusArea": "Area de enfoque", + "unavailableTitle": "Preparación no disponible", + "loadingContextDescription": "Comprobando el contexto del puesto para este currículum adaptado antes de generar.", + "missingContextDescription": "Este currículum adaptado no tiene el contexto del puesto guardado que se necesita para generar preparación de entrevista. Vuelve a adaptar el currículum con una descripción del puesto.", + "saveRequiredDescription": "Guarda este currículum adaptado antes de generar preparación de entrevista.", + "focusArea": "Área de enfoque", "suggestedAnswerPoints": "Puntos sugeridos para responder", - "whyItMatters": "Por que importa", - "preparationSuggestion": "Sugerencia de preparacion", + "whyItMatters": "Por qué importa", + "preparationSuggestion": "Sugerencia de preparación", "sections": { - "roleFit": "Analisis de encaje", - "resumeQuestions": "Preguntas basadas en el curriculum", + "roleFit": "Análisis de encaje", + "resumeQuestions": "Preguntas basadas en el currículum", "projectFollowUps": "Seguimiento de proyectos", "skillGaps": "Brechas de habilidades", "talkingPoints": "Puntos clave" diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index 7ac5ee142..6a528ec2b 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -177,7 +177,7 @@ "coverLetter": "CARTA DE APRESENTAÇÃO", "outreach": "MENSAGEM DE CONTATO", "jdMatch": "ANÁLISE DE VAGA", - "interviewPrep": "PREPARACAO" + "interviewPrep": "PREPARAÇÃO" }, "footer": { "moduleLabel": "Módulo de Criação de Currículo", @@ -197,8 +197,8 @@ "generateButton": "Gerar {title}", "coverLetterFooter": "Dica: personalize primeiro para melhores resultados.", "outreachFooter": "Dica: mantenha curta e personalizada.", - "interviewPrepDescription": "Gere preparacao para entrevista com base no curriculo adaptado e na descricao da vaga.", - "interviewPrepFooter": "Dica: use depois de adaptar o curriculo para preparar respostas verdadeiras e baseadas em evidencias." + "interviewPrepDescription": "Gere preparação para entrevista com base no currículo adaptado e na descrição da vaga.", + "interviewPrepFooter": "Dica: use depois de adaptar o currículo para preparar respostas verdadeiras e baseadas em evidências." }, "regenerateDialog": { "title": "Regenerar {title}?", @@ -448,7 +448,7 @@ "coverLetterEditor": "Editor de Carta de Apresentação", "outreachEditor": "Editor de Mensagem de Contato", "jdMatchAnalysis": "Análise de Correspondência de Vaga", - "interviewPrep": "Preparacao para entrevista" + "interviewPrep": "Preparação para entrevista" }, "alerts": { "saveNotAvailable": "Salvar só está disponível ao editar um currículo existente.", @@ -466,7 +466,7 @@ "outreachSaveFailed": "Falha ao salvar mensagem de contato. Por favor, tente novamente.", "coverLetterGenerateFailed": "Falha ao gerar carta de apresentação: {error}", "outreachGenerateFailed": "Falha ao gerar mensagem de contato: {error}", - "interviewPrepGenerateFailed": "Falha ao gerar preparacao para entrevista: {error}" + "interviewPrepGenerateFailed": "Falha ao gerar preparação para entrevista: {error}" } }, "enrichment": { @@ -712,8 +712,8 @@ "customPromptResetButton": "Restaurar padrão", "customPromptErrorMissing": "Marcadores obrigatórios ausentes: {missing}", "interviewPrep": { - "label": "Preparacao para entrevista", - "description": "Gerar automaticamente preparacao para entrevista junto com curriculos adaptados salvos" + "label": "Preparação para entrevista", + "description": "Gerar automaticamente preparação para entrevista junto com currículos adaptados salvos" } }, "promptSettings": { @@ -1017,19 +1017,19 @@ } }, "interviewPrep": { - "title": "Preparacao para entrevista", + "title": "Preparação para entrevista", "regenerate": "Gerar novamente", - "unavailableTitle": "Preparacao indisponivel", - "loadingContextDescription": "Verificando o contexto da vaga para este curriculo adaptado antes de gerar.", - "missingContextDescription": "Este curriculo adaptado nao tem o contexto da vaga salvo necessario para gerar preparacao para entrevista. Adapte o curriculo novamente com uma descricao da vaga.", - "saveRequiredDescription": "Salve este curriculo adaptado antes de gerar preparacao para entrevista.", - "focusArea": "Area de foco", + "unavailableTitle": "Preparação indisponível", + "loadingContextDescription": "Verificando o contexto da vaga para este currículo adaptado antes de gerar.", + "missingContextDescription": "Este currículo adaptado não tem o contexto da vaga salvo necessário para gerar preparação para entrevista. Adapte o currículo novamente com uma descrição da vaga.", + "saveRequiredDescription": "Salve este currículo adaptado antes de gerar preparação para entrevista.", + "focusArea": "Área de foco", "suggestedAnswerPoints": "Pontos sugeridos para resposta", "whyItMatters": "Por que importa", - "preparationSuggestion": "Sugestao de preparacao", + "preparationSuggestion": "Sugestão de preparação", "sections": { - "roleFit": "Analise de adequacao", - "resumeQuestions": "Perguntas baseadas no curriculo", + "roleFit": "Análise de adequação", + "resumeQuestions": "Perguntas baseadas no currículo", "projectFollowUps": "Perguntas sobre projetos", "skillGaps": "Lacunas de habilidades", "talkingPoints": "Pontos de conversa" diff --git a/apps/frontend/tests/interview-prep-view.test.tsx b/apps/frontend/tests/interview-prep-view.test.tsx index 08ee87e11..46fd65065 100644 --- a/apps/frontend/tests/interview-prep-view.test.tsx +++ b/apps/frontend/tests/interview-prep-view.test.tsx @@ -107,4 +107,19 @@ describe('InterviewPrepView', () => { expect(screen.getByText('Kubernetes')).toBeInTheDocument(); expect(screen.getByText('Connect API work to the role.')).toBeInTheDocument(); }); + + it('shows a default unavailable message for generated prep when regeneration is blocked', () => { + render( + + ); + + expect(screen.getByText('interviewPrep.missingContextDescription')).toBeInTheDocument(); + expect(screen.getByText('Backend API experience fits the role.')).toBeInTheDocument(); + }); }); diff --git a/docs/agent/apis/backend-requirements.md b/docs/agent/apis/backend-requirements.md index 39cc356a1..867f74d0e 100644 --- a/docs/agent/apis/backend-requirements.md +++ b/docs/agent/apis/backend-requirements.md @@ -77,6 +77,23 @@ GET /jobs/{id} → {job_id, content, created_at} } ``` +### ResumeData +`PATCH /resumes/{id}` accepts the structured resume payload stored in +`processed_data`, for example: + +```json +{ + "personalInfo": {...}, + "summary": "...", + "workExperience": [...], + "education": [...], + "personalProjects": [...], + "additional": {...}, + "customSections": {...}, + "sectionMeta": [...] +} +``` + ### Error Response ```json { From 2463d1ad347ca28b39004b823db421d2833d5fa8 Mon Sep 17 00:00:00 2001 From: GwIhViEte <1007435607@qq.com> Date: Thu, 2 Jul 2026 21:14:16 +0800 Subject: [PATCH 3/4] Address follow-up interview prep review comments --- apps/backend/app/routers/resumes.py | 4 ++-- apps/backend/app/services/interview_prep.py | 11 +++++++++-- .../tests/unit/test_interview_prep_service.py | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index a4ce4b37d..593078f78 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -530,7 +530,7 @@ async def _generate_auxiliary_messages( title = None interview_prep = None warnings: list[str] = [] - generation_tasks: list[Awaitable[Any]] = [] + generation_tasks: list[Awaitable[str | InterviewPrepData]] = [] task_labels: list[str] = [] # Title generation is always on (no feature flag) @@ -1901,7 +1901,7 @@ async def generate_interview_prep_endpoint( language, ) except Exception as e: - logger.error("Interview preparation generation failed: %s", e) + logger.exception("Interview preparation generation failed: %s", e) raise HTTPException( status_code=500, detail="Failed to generate interview preparation. Please try again.", diff --git a/apps/backend/app/services/interview_prep.py b/apps/backend/app/services/interview_prep.py index 635359f75..bc6d90920 100644 --- a/apps/backend/app/services/interview_prep.py +++ b/apps/backend/app/services/interview_prep.py @@ -3,7 +3,12 @@ import json from typing import Any -from app.llm import complete_json +from app.llm import ( + complete_json, + get_llm_config, + get_model_name, + get_safe_max_tokens, +) from app.prompts import INTERVIEW_PREP_PROMPT, get_language_name from app.schemas import InterviewPrepData @@ -107,6 +112,8 @@ async def generate_interview_prep( resume_data=_serialize_resume_data_for_prompt(resume_data), output_language=get_language_name(language), ) + config = get_llm_config() + max_tokens = get_safe_max_tokens(get_model_name(config), requested=8192) result = await complete_json( prompt=prompt, @@ -114,7 +121,7 @@ async def generate_interview_prep( "You are a career interview coach. Output truthful, resume-grounded " "interview preparation as JSON only." ), - max_tokens=8192, + max_tokens=max_tokens, schema_type="interview_prep", ) diff --git a/apps/backend/tests/unit/test_interview_prep_service.py b/apps/backend/tests/unit/test_interview_prep_service.py index c77ba6639..7160d0d6f 100644 --- a/apps/backend/tests/unit/test_interview_prep_service.py +++ b/apps/backend/tests/unit/test_interview_prep_service.py @@ -43,14 +43,25 @@ async def test_generate_interview_prep_validates_successful_json(): with patch( "app.services.interview_prep.complete_json", new_callable=AsyncMock, - ) as mock_complete: + ) as mock_complete, patch( + "app.services.interview_prep.get_model_name", + return_value="openai/small-output-model", + ) as mock_get_model_name, patch( + "app.services.interview_prep.get_safe_max_tokens", + return_value=4096, + ) as mock_get_safe_max_tokens: mock_complete.return_value = _valid_payload() result = await generate_interview_prep(SAMPLE_RESUME, "Need FastAPI", "en") assert result.role_fit_analysis == ["Python API experience is relevant."] mock_complete.assert_awaited_once() - assert mock_complete.await_args.kwargs["max_tokens"] == 8192 + mock_get_model_name.assert_called_once() + mock_get_safe_max_tokens.assert_called_once_with( + "openai/small-output-model", + requested=8192, + ) + assert mock_complete.await_args.kwargs["max_tokens"] == 4096 assert mock_complete.await_args.kwargs["schema_type"] == "interview_prep" From c11161b79edfa8162521053c7772af76559177b6 Mon Sep 17 00:00:00 2001 From: GwIhViEte <1007435607@qq.com> Date: Thu, 2 Jul 2026 21:26:42 +0800 Subject: [PATCH 4/4] Isolate interview prep service tests --- .../tests/unit/test_interview_prep_service.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/backend/tests/unit/test_interview_prep_service.py b/apps/backend/tests/unit/test_interview_prep_service.py index 7160d0d6f..1f48ade81 100644 --- a/apps/backend/tests/unit/test_interview_prep_service.py +++ b/apps/backend/tests/unit/test_interview_prep_service.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from unittest.mock import AsyncMock, patch import pytest @@ -38,25 +39,37 @@ def _valid_payload(): } -@pytest.mark.asyncio -async def test_generate_interview_prep_validates_successful_json(): +@contextmanager +def _patched_llm_token_helpers(max_tokens: int = 4096): + config = object() with patch( - "app.services.interview_prep.complete_json", - new_callable=AsyncMock, - ) as mock_complete, patch( + "app.services.interview_prep.get_llm_config", + return_value=config, + ) as mock_get_llm_config, patch( "app.services.interview_prep.get_model_name", return_value="openai/small-output-model", ) as mock_get_model_name, patch( "app.services.interview_prep.get_safe_max_tokens", - return_value=4096, + return_value=max_tokens, ) as mock_get_safe_max_tokens: + yield mock_get_llm_config, mock_get_model_name, mock_get_safe_max_tokens + + +@pytest.mark.asyncio +async def test_generate_interview_prep_validates_successful_json(): + with patch( + "app.services.interview_prep.complete_json", + new_callable=AsyncMock, + ) as mock_complete, _patched_llm_token_helpers() as token_helpers: mock_complete.return_value = _valid_payload() result = await generate_interview_prep(SAMPLE_RESUME, "Need FastAPI", "en") + mock_get_llm_config, mock_get_model_name, mock_get_safe_max_tokens = token_helpers assert result.role_fit_analysis == ["Python API experience is relevant."] mock_complete.assert_awaited_once() - mock_get_model_name.assert_called_once() + mock_get_llm_config.assert_called_once_with() + mock_get_model_name.assert_called_once_with(mock_get_llm_config.return_value) mock_get_safe_max_tokens.assert_called_once_with( "openai/small-output-model", requested=8192, @@ -70,7 +83,7 @@ async def test_generate_interview_prep_bounds_prompt_inputs(): with patch( "app.services.interview_prep.complete_json", new_callable=AsyncMock, - ) as mock_complete: + ) as mock_complete, _patched_llm_token_helpers(): mock_complete.return_value = _valid_payload() large_resume = { @@ -94,7 +107,7 @@ async def test_generate_interview_prep_rejects_malformed_llm_json(): with patch( "app.services.interview_prep.complete_json", new_callable=AsyncMock, - ) as mock_complete: + ) as mock_complete, _patched_llm_token_helpers(): mock_complete.return_value = { "role_fit_analysis": ["Only one required key is present."] }