Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -191,6 +192,7 @@ async def create_resume(
outreach_message: 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.

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 @@ -249,6 +253,7 @@ async def create_resume_atomic_master(
outreach_message: 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.

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
54 changes: 54 additions & 0 deletions apps/backend/app/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,60 @@ 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}.
Do NOT translate JSON property names. Keep every JSON key exactly as shown in the schema; translate only string values.

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