Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
title: str | None = None,
original_markdown: str | None = None,
) -> dict[str, Any]:
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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]:
Expand Down Expand Up @@ -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,
)
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/app/db_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
30 changes: 26 additions & 4 deletions apps/backend/app/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }."
Expand Down
1 change: 1 addition & 0 deletions apps/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions apps/backend/app/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"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}.
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/app/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand All @@ -238,13 +239,16 @@ 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)

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),
)


Expand Down
Loading