Skip to content

Commit 67c24e7

Browse files
committed
Merge branch 'innerloopinc/REQ-2-on-demand-interview-prep-pipeline'
2 parents 7269eb3 + 1e90af9 commit 67c24e7

5 files changed

Lines changed: 615 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""add_interview_prep_sheet
2+
3+
Revision ID: b16bb02fc7ed
4+
Revises: a18ea51b76fd
5+
Create Date: 2026-05-02 11:19:16.704168
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'b16bb02fc7ed'
16+
down_revision: Union[str, Sequence[str], None] = 'a18ea51b76fd'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('interview_prep_sheets',
25+
sa.Column('id', sa.Integer(), nullable=False),
26+
sa.Column('job_application_id', sa.Integer(), nullable=False),
27+
sa.Column('status', sa.String(), nullable=False),
28+
sa.Column('company_snapshot', sa.Text(), nullable=True),
29+
sa.Column('role_requirements_summary', sa.Text(), nullable=True),
30+
sa.Column('likely_technical_questions', sa.Text(), nullable=True),
31+
sa.Column('likely_behavioral_questions', sa.Text(), nullable=True),
32+
sa.Column('talking_points', sa.Text(), nullable=True),
33+
sa.Column('gaps_or_risks', sa.Text(), nullable=True),
34+
sa.Column('prep_plan_30_min', sa.Text(), nullable=True),
35+
sa.Column('error_message', sa.Text(), nullable=True),
36+
sa.Column('generated_at', sa.DateTime(), nullable=True),
37+
sa.Column('created_at', sa.DateTime(), nullable=True),
38+
sa.ForeignKeyConstraint(['job_application_id'], ['jobs.id'], ),
39+
sa.PrimaryKeyConstraint('id'),
40+
sa.UniqueConstraint('job_application_id')
41+
)
42+
# ### end Alembic commands ###
43+
44+
45+
def downgrade() -> None:
46+
"""Downgrade schema."""
47+
# ### commands auto generated by Alembic - please adjust! ###
48+
op.drop_table('interview_prep_sheets')
49+
# ### end Alembic commands ###

models/database.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import datetime
2-
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, UniqueConstraint
2+
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, UniqueConstraint, ForeignKey
33
from sqlalchemy.orm import declarative_base
44

55
Base = declarative_base()
@@ -62,3 +62,20 @@ class ApplicationHistory(Base):
6262
__table_args__ = (
6363
UniqueConstraint("company", "job_title", name="_company_job_uc"),
6464
)
65+
66+
class InterviewPrepSheet(Base):
67+
__tablename__ = "interview_prep_sheets"
68+
69+
id = Column(Integer, primary_key=True)
70+
job_application_id = Column(Integer, ForeignKey("jobs.id"), unique=True, nullable=False)
71+
status = Column(String, nullable=False, default="processing")
72+
company_snapshot = Column(Text, nullable=True)
73+
role_requirements_summary = Column(Text, nullable=True)
74+
likely_technical_questions = Column(Text, nullable=True)
75+
likely_behavioral_questions = Column(Text, nullable=True)
76+
talking_points = Column(Text, nullable=True)
77+
gaps_or_risks = Column(Text, nullable=True)
78+
prep_plan_30_min = Column(Text, nullable=True)
79+
error_message = Column(Text, nullable=True)
80+
generated_at = Column(DateTime, nullable=True)
81+
created_at = Column(DateTime, default=datetime.datetime.utcnow)

run_pipeline.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,49 @@ def cover_letter_cmd(job_id: int, profile: str, model: str, regenerate: bool):
13521352
session.close()
13531353

13541354

1355+
@cli.command(name='interview-prep')
1356+
@click.argument('job_application_id', type=int)
1357+
@click.option('--profile', default='profile.yaml', show_default=True, help='Path to candidate profile YAML')
1358+
def interview_prep_cmd(job_application_id: int, profile: str):
1359+
"""Generate a personalized interview prep sheet for a job application."""
1360+
from utils.interview_prep import run_interview_prep
1361+
1362+
profile_data = _load_profile(profile)
1363+
if not profile_data:
1364+
click.echo(click.style(f"Could not load profile from {profile}", fg="red"), err=True)
1365+
sys.exit(1)
1366+
1367+
def _progress(step: int, msg: str) -> None:
1368+
click.echo(f"[{step}/6] {msg}")
1369+
1370+
session = SessionLocal()
1371+
try:
1372+
sheet = run_interview_prep(job_application_id, profile_data, session, progress_callback=_progress)
1373+
click.echo(click.style(f"\n✅ Interview prep sheet saved (ID: {sheet.id})", fg="green"))
1374+
1375+
import json as _json
1376+
sections = [
1377+
("Company Snapshot", sheet.company_snapshot),
1378+
("Role Summary", sheet.role_requirements_summary),
1379+
("Technical Questions", sheet.likely_technical_questions),
1380+
("Behavioral Questions", sheet.likely_behavioral_questions),
1381+
("Talking Points", sheet.talking_points),
1382+
("Gaps / Risks", sheet.gaps_or_risks),
1383+
("30-Min Prep Plan", sheet.prep_plan_30_min),
1384+
]
1385+
for title, raw in sections:
1386+
click.echo(click.style(f"\n--- {title} ---", fg="cyan"))
1387+
try:
1388+
click.echo(_json.dumps(_json.loads(raw), indent=2, ensure_ascii=False))
1389+
except (TypeError, ValueError):
1390+
click.echo(str(raw))
1391+
except (ValueError, RuntimeError) as exc:
1392+
click.echo(click.style(str(exc), fg="red"), err=True)
1393+
sys.exit(1)
1394+
finally:
1395+
session.close()
1396+
1397+
13551398
@cli.command(name='perf')
13561399
@click.option('--job', default=None, help='Filter to runs matching this job substring (e.g. "Coinbase")')
13571400
@click.option('--last', default=10, type=int, show_default=True, help='Number of most-recent runs to plot')

tests/test_interview_prep.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Tests for utils/interview_prep.py.
3+
4+
Uses an in-memory SQLite session and mocks requests.post so no real
5+
Ollama instance is needed.
6+
"""
7+
import json
8+
from unittest.mock import MagicMock, call, patch
9+
10+
import pytest
11+
import requests
12+
from sqlalchemy import create_engine
13+
from sqlalchemy.orm import sessionmaker
14+
15+
import config
16+
from models.database import Base, InterviewPrepSheet, Job
17+
from utils.interview_prep import run_interview_prep
18+
19+
20+
# ---------------------------------------------------------------------------
21+
# Fixtures
22+
# ---------------------------------------------------------------------------
23+
24+
@pytest.fixture
25+
def engine():
26+
e = create_engine("sqlite:///:memory:")
27+
Base.metadata.create_all(e)
28+
yield e
29+
Base.metadata.drop_all(e)
30+
31+
32+
@pytest.fixture
33+
def session(engine):
34+
S = sessionmaker(bind=engine)
35+
s = S()
36+
yield s
37+
s.close()
38+
39+
40+
@pytest.fixture
41+
def profile():
42+
return {
43+
"personal": {"name": "Jane Doe", "current_title": "Backend Engineer"},
44+
"skills": ["Python", "SQL", "Docker"],
45+
"work_history": [
46+
{
47+
"company": "Acme",
48+
"title": "Senior Engineer",
49+
"from": "2020",
50+
"to": "present",
51+
"highlights": ["Built scalable APIs", "Led team of 5"],
52+
}
53+
],
54+
}
55+
56+
57+
@pytest.fixture
58+
def job(session):
59+
j = Job(
60+
external_id="interview-test-001",
61+
source="remotive",
62+
company="TechCorp",
63+
title="Senior Backend Engineer",
64+
location="Remote",
65+
url="https://example.com/jobs/1",
66+
description="<p>We need a Python expert with SQL and Docker skills.</p>",
67+
description_text="We need a Python expert with SQL and Docker skills.",
68+
status="shortlisted",
69+
)
70+
session.add(j)
71+
session.commit()
72+
session.refresh(j)
73+
return j
74+
75+
76+
def _ollama_response(content: dict) -> MagicMock:
77+
mock = MagicMock()
78+
mock.raise_for_status.return_value = None
79+
mock.json.return_value = {"message": {"content": json.dumps(content)}}
80+
return mock
81+
82+
83+
CONTEXT_RESPONSE = {
84+
"company_snapshot": {
85+
"industry": "SaaS",
86+
"likely_size": "50-200",
87+
"culture_signals": ["fast-paced"],
88+
"red_flags": [],
89+
},
90+
"role_summary": {
91+
"core_responsibilities": ["Build APIs"],
92+
"must_have_skills": ["Python", "SQL"],
93+
"nice_to_have_skills": ["Docker"],
94+
"seniority_signals": "Senior",
95+
},
96+
}
97+
98+
QUESTIONS_RESPONSE = {
99+
"technical_questions": ["Describe your Python API experience.", "How do you optimize SQL queries?"],
100+
"behavioral_questions": ["Tell me about a time you led a project.", "How do you handle deadlines?"],
101+
}
102+
103+
MAPPING_RESPONSE = {
104+
"talking_points": [
105+
{
106+
"jd_requirement": "Python expertise",
107+
"candidate_evidence": "Built scalable APIs at Acme",
108+
"suggested_story": "Discuss the API work at Acme",
109+
}
110+
],
111+
"gaps_or_risks": [],
112+
}
113+
114+
ACTION_PLAN_RESPONSE = {
115+
"minutes_0_10": ["Review company website"],
116+
"minutes_10_20": ["Practice Python API questions"],
117+
"minutes_20_30": ["Rehearse STAR stories"],
118+
"priority_note": "Emphasize Python API experience",
119+
}
120+
121+
122+
# ---------------------------------------------------------------------------
123+
# Tests
124+
# ---------------------------------------------------------------------------
125+
126+
def test_run_interview_prep_success(session, job, profile):
127+
responses = [
128+
_ollama_response(CONTEXT_RESPONSE),
129+
_ollama_response(QUESTIONS_RESPONSE),
130+
_ollama_response(MAPPING_RESPONSE),
131+
_ollama_response(ACTION_PLAN_RESPONSE),
132+
]
133+
with patch("utils.interview_prep.requests.post", side_effect=responses):
134+
sheet = run_interview_prep(job.id, profile, session)
135+
136+
assert sheet.status == "completed"
137+
assert sheet.job_application_id == job.id
138+
assert sheet.generated_at is not None
139+
140+
# All 7 sections populated
141+
assert json.loads(sheet.company_snapshot)["industry"] == "SaaS"
142+
assert json.loads(sheet.role_requirements_summary)["seniority_signals"] == "Senior"
143+
assert len(json.loads(sheet.likely_technical_questions)) == 2
144+
assert len(json.loads(sheet.likely_behavioral_questions)) == 2
145+
assert len(json.loads(sheet.talking_points)) == 1
146+
assert json.loads(sheet.gaps_or_risks) == []
147+
assert json.loads(sheet.prep_plan_30_min)["priority_note"] == "Emphasize Python API experience"
148+
149+
150+
def test_run_interview_prep_invalid_job_id(session, profile):
151+
with pytest.raises(ValueError, match="not found"):
152+
run_interview_prep(99999, profile, session)
153+
154+
# No sheet should have been created
155+
count = session.query(InterviewPrepSheet).count()
156+
assert count == 0
157+
158+
159+
def test_run_interview_prep_missing_description(session, profile):
160+
empty_job = Job(
161+
external_id="interview-nodesc-001",
162+
source="remotive",
163+
company="TechCorp",
164+
title="Engineer",
165+
location="Remote",
166+
url="https://example.com/jobs/2",
167+
description=None,
168+
description_text=None,
169+
status="shortlisted",
170+
)
171+
session.add(empty_job)
172+
session.commit()
173+
session.refresh(empty_job)
174+
175+
with pytest.raises(ValueError, match="no description"):
176+
run_interview_prep(empty_job.id, profile, session)
177+
178+
sheet = session.query(InterviewPrepSheet).filter(
179+
InterviewPrepSheet.job_application_id == empty_job.id
180+
).first()
181+
assert sheet is not None
182+
assert sheet.status == "failed"
183+
assert "empty" in sheet.error_message.lower()
184+
185+
186+
def test_run_interview_prep_timeout_retries(session, job, profile):
187+
with patch("utils.interview_prep.requests.post", side_effect=requests.Timeout("timed out")):
188+
with patch("utils.interview_prep.time.sleep"):
189+
with pytest.raises(RuntimeError, match="timed out"):
190+
run_interview_prep(job.id, profile, session)
191+
192+
sheet = session.query(InterviewPrepSheet).filter(
193+
InterviewPrepSheet.job_application_id == job.id
194+
).first()
195+
assert sheet is not None
196+
assert sheet.status == "failed"
197+
assert "timed out" in sheet.error_message.lower()

0 commit comments

Comments
 (0)