husseinelsaadi commited on
Commit
6d75d91
·
1 Parent(s): bb4f7d7
app.py CHANGED
@@ -232,6 +232,11 @@ def post_job():
232
  seniority = request.form.get('seniority', '').strip()
233
  skills_input = request.form.get('skills', '').strip()
234
  company = request.form.get('company', '').strip()
 
 
 
 
 
235
 
236
  errors = []
237
  if not role_title:
@@ -244,6 +249,18 @@ def post_job():
244
  errors.append('Skills are required.')
245
  if not company:
246
  errors.append('Company name is required.')
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
  if errors:
249
  for err in errors:
@@ -259,7 +276,8 @@ def post_job():
259
  seniority=seniority,
260
  skills=skills_json,
261
  company=company,
262
- recruiter_id=current_user.id
 
263
  )
264
  db.session.add(new_job)
265
  db.session.commit()
 
232
  seniority = request.form.get('seniority', '').strip()
233
  skills_input = request.form.get('skills', '').strip()
234
  company = request.form.get('company', '').strip()
235
+ # New field: number of interview questions. Recruiters can specify
236
+ # how many questions the interview should contain. Default to 3 if
237
+ # the value is missing or invalid. See templates/post_job.html for
238
+ # the corresponding input element.
239
+ num_questions_raw = request.form.get('num_questions', '').strip()
240
 
241
  errors = []
242
  if not role_title:
 
249
  errors.append('Skills are required.')
250
  if not company:
251
  errors.append('Company name is required.')
252
+ # Validate number of questions; must be a positive integer. Store
253
+ # errors if the input is provided but invalid. Missing values will
254
+ # fall back to the default of 3.
255
+ num_questions = 3
256
+ if num_questions_raw:
257
+ try:
258
+ parsed_nq = int(num_questions_raw)
259
+ if parsed_nq <= 0:
260
+ raise ValueError()
261
+ num_questions = parsed_nq
262
+ except ValueError:
263
+ errors.append('Number of interview questions must be a positive integer.')
264
 
265
  if errors:
266
  for err in errors:
 
276
  seniority=seniority,
277
  skills=skills_json,
278
  company=company,
279
+ recruiter_id=current_user.id,
280
+ num_questions=num_questions
281
  )
282
  db.session.add(new_job)
283
  db.session.commit()
backend/models/database.py CHANGED
@@ -18,6 +18,14 @@ class Job(db.Model):
18
  company = db.Column(db.String(100), nullable=False)
19
  date_posted = db.Column(db.DateTime, default=datetime.utcnow)
20
 
 
 
 
 
 
 
 
 
21
  recruiter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
22
  recruiter = db.relationship('User', backref='posted_jobs')
23
 
@@ -74,13 +82,19 @@ class Application(db.Model):
74
  def init_db(app):
75
  db.init_app(app)
76
  with app.app_context():
 
 
 
77
  db.create_all()
78
- # Database tables are created on application start. We intentionally do not
79
- # seed any sample data here. Previously a block of code inserted dummy
80
- # job listings whenever the jobs table was empty. In production, jobs
81
- # should only be added by authenticated recruiters via the job posting
82
- # interface. Leaving the seeding logic in place would result in fake
83
- # positions appearing every time the application starts, which is
84
- # undesirable for a live recruitment platform. If your environment
85
- # requires initial data for testing, insert it manually via the
86
- # database or through the new recruiter job posting page.
 
 
 
 
18
  company = db.Column(db.String(100), nullable=False)
19
  date_posted = db.Column(db.DateTime, default=datetime.utcnow)
20
 
21
+ # Number of interview questions for this job. Recruiters can set this
22
+ # value when posting a job. Defaults to 3 to preserve existing
23
+ # behaviour where the interview consists of three questions. The
24
+ # interview API uses this field to determine when to stop asking
25
+ # follow‑up questions. See backend/routes/interview_api.py for
26
+ # details.
27
+ num_questions = db.Column(db.Integer, nullable=False, default=3)
28
+
29
  recruiter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
30
  recruiter = db.relationship('User', backref='posted_jobs')
31
 
 
82
  def init_db(app):
83
  db.init_app(app)
84
  with app.app_context():
85
+ # Create any missing tables. SQLAlchemy does not automatically add
86
+ # columns to existing tables, so we call create_all() first to ensure
87
+ # new tables (like ``applications`` or ``jobs``) are present.
88
  db.create_all()
89
+
90
+ from sqlalchemy import inspect
91
+ inspector = inspect(db.engine)
92
+ try:
93
+ columns = [col['name'] for col in inspector.get_columns('jobs')]
94
+ if 'num_questions' not in columns:
95
+ db.session.execute('ALTER TABLE jobs ADD COLUMN num_questions INTEGER NOT NULL DEFAULT 3')
96
+ db.session.commit()
97
+ except Exception:
98
+ pass
99
+
100
+
backend/routes/interview_api.py CHANGED
@@ -131,7 +131,12 @@ def process_answer():
131
  data = request.get_json() or {}
132
  answer = data.get("answer", "").strip()
133
  question_idx = data.get("questionIndex", 0)
134
-
 
 
 
 
 
135
  if not answer:
136
  return jsonify({"error": "No answer provided."}), 400
137
 
@@ -141,33 +146,59 @@ def process_answer():
141
  # Evaluate the answer
142
  evaluation_result = evaluate_answer(current_question, answer)
143
 
144
- # Determine completion (3 questions in total, zero‑based index)
145
- is_complete = question_idx >= 2
146
-
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  next_question_text = None
148
  audio_url = None
149
-
150
  if not is_complete:
151
- # Generate the next question based on the current question index.
152
- #
153
- # Question indices are zero‑based: 0 for the first follow‑up question,
154
- # 1 for the second, and so on. We want the final (third) question
155
- # delivered by this route to always probe the candidate's salary
156
- # expectations and preferred working arrangement. After the user
157
- # answers this question (i.e. when ``question_idx`` becomes 2), the
158
- # interview is considered complete and no further questions are
159
- # generated.
160
- if question_idx == 0:
161
- next_question_text = "Can you describe a challenging project you've worked on and how you overcame the difficulties?"
162
- elif question_idx == 1:
163
- # Salary expectations question for the final interview round
164
- next_question_text = (
165
- "What are your salary expectations? Are you looking for a full-time or part-time role, "
166
- "and do you prefer remote or on-site work?"
167
- )
 
 
 
 
 
 
 
 
 
168
  else:
169
- # Fallback for unexpected indices; ask if the candidate has any questions.
170
- next_question_text = "Do you have any questions about the role or our company?"
 
 
 
 
171
 
172
  # Try to generate audio for the next question
173
  try:
@@ -175,7 +206,7 @@ def process_answer():
175
  os.makedirs(audio_dir, exist_ok=True)
176
  filename = f"q_{uuid.uuid4().hex}.wav"
177
  audio_path = os.path.join(audio_dir, filename)
178
-
179
  audio_result = edge_tts_to_file_sync(next_question_text, audio_path)
180
  if audio_result and os.path.exists(audio_path) and os.path.getsize(audio_path) > 1000:
181
  audio_url = url_for("interview_api.get_audio", filename=filename)
@@ -191,7 +222,7 @@ def process_answer():
191
  "evaluation": evaluation_result,
192
  "is_complete": is_complete
193
  })
194
-
195
  except Exception as e:
196
  logging.error(f"Error in process_answer: {e}")
197
  return jsonify({"error": "Error processing answer. Please try again."}), 500
 
131
  data = request.get_json() or {}
132
  answer = data.get("answer", "").strip()
133
  question_idx = data.get("questionIndex", 0)
134
+
135
+ # ``job_id`` is required to determine how many total questions are
136
+ # expected for this interview. Without it we fall back to a
137
+ # three‑question interview.
138
+ job_id = data.get("job_id")
139
+
140
  if not answer:
141
  return jsonify({"error": "No answer provided."}), 400
142
 
 
146
  # Evaluate the answer
147
  evaluation_result = evaluate_answer(current_question, answer)
148
 
149
+ # Determine the number of questions configured for this job
150
+ total_questions = 3
151
+ if job_id is not None:
152
+ try:
153
+ job = Job.query.get(int(job_id))
154
+ if job and job.num_questions and job.num_questions > 0:
155
+ total_questions = job.num_questions
156
+ except Exception:
157
+ # If lookup fails, keep default
158
+ pass
159
+
160
+ # Check completion. ``question_idx`` is zero‑based; the last index
161
+ # corresponds to ``total_questions - 1``. When the current index
162
+ # reaches or exceeds this value, the interview is complete.
163
+ is_complete = question_idx >= (total_questions - 1)
164
+
165
  next_question_text = None
166
  audio_url = None
167
+
168
  if not is_complete:
169
+ # Follow‑up question bank. These are used for indices 1 .. n‑2.
170
+ # The final question (last index) probes salary expectations and
171
+ # working preferences. If the recruiter has configured fewer
172
+ # questions than the number of entries here, only the first
173
+ # appropriate number will be used.
174
+ follow_up_questions = [
175
+ "Can you describe a challenging project you've worked on and how you overcame the difficulties?",
176
+ "What is your favorite machine learning algorithm and why?",
177
+ "How do you stay up-to-date with advancements in AI?",
178
+ "Describe a time you had to learn a new technology quickly. How did you approach it?"
179
+ ]
180
+ final_question = (
181
+ "What are your salary expectations? Are you looking for a full-time or part-time role, "
182
+ "and do you prefer remote or on-site work?"
183
+ )
184
+
185
+ # Compute the next index (zero‑based) for the upcoming question
186
+ next_idx = question_idx + 1
187
+
188
+ # Determine which question to ask next. If next_idx is the last
189
+ # question (i.e. equals total_questions - 1), use the final
190
+ # question. Otherwise, select a follow‑up question from the
191
+ # bank based on ``next_idx - 1`` (because index 0 is for the
192
+ # first follow‑up). If out of range, cycle through the list.
193
+ if next_idx == (total_questions - 1):
194
+ next_question_text = final_question
195
  else:
196
+ if follow_up_questions:
197
+ idx_in_bank = (next_idx - 1) % len(follow_up_questions)
198
+ next_question_text = follow_up_questions[idx_in_bank]
199
+ else:
200
+ # Fallback if no follow‑ups are defined
201
+ next_question_text = "Do you have any questions about the role or our company?"
202
 
203
  # Try to generate audio for the next question
204
  try:
 
206
  os.makedirs(audio_dir, exist_ok=True)
207
  filename = f"q_{uuid.uuid4().hex}.wav"
208
  audio_path = os.path.join(audio_dir, filename)
209
+
210
  audio_result = edge_tts_to_file_sync(next_question_text, audio_path)
211
  if audio_result and os.path.exists(audio_path) and os.path.getsize(audio_path) > 1000:
212
  audio_url = url_for("interview_api.get_audio", filename=filename)
 
222
  "evaluation": evaluation_result,
223
  "is_complete": is_complete
224
  })
225
+
226
  except Exception as e:
227
  logging.error(f"Error in process_answer: {e}")
228
  return jsonify({"error": "Error processing answer. Please try again."}), 500
backend/templates/interview.html CHANGED
@@ -429,7 +429,7 @@
429
  <div class="interview-container">
430
  <div class="header">
431
  <div class="question-counter">
432
- Question <span id="currentQuestionNum">1</span> of <span id="totalQuestions">3</span>
433
  </div>
434
  <h1>🤖 AI Interview Assistant</h1>
435
  <p>Answer thoughtfully and take your time</p>
@@ -494,7 +494,19 @@
494
  class AIInterviewer {
495
  constructor() {
496
  this.currentQuestionIndex = 0;
497
- this.totalQuestions = 3;
 
 
 
 
 
 
 
 
 
 
 
 
498
  this.isRecording = false;
499
  this.mediaRecorder = null;
500
  this.audioChunks = [];
@@ -850,7 +862,8 @@
850
  body: JSON.stringify({
851
  answer: answer,
852
  questionIndex: this.currentQuestionIndex,
853
- current_question: this.currentQuestion
 
854
  })
855
  });
856
 
 
429
  <div class="interview-container">
430
  <div class="header">
431
  <div class="question-counter">
432
+ Question <span id="currentQuestionNum">1</span> of <span id="totalQuestions">{{ job.num_questions }}</span>
433
  </div>
434
  <h1>🤖 AI Interview Assistant</h1>
435
  <p>Answer thoughtfully and take your time</p>
 
494
  class AIInterviewer {
495
  constructor() {
496
  this.currentQuestionIndex = 0;
497
+ // Set the total number of questions based on the job configuration.
498
+ // The value is read from the DOM element #totalQuestions, which
499
+ // has been populated server‑side using ``job.num_questions``.
500
+ const totalSpan = document.getElementById('totalQuestions');
501
+ const parsedTotal = parseInt(totalSpan && totalSpan.textContent);
502
+ this.totalQuestions = Number.isNaN(parsedTotal) || parsedTotal <= 0 ? 3 : parsedTotal;
503
+ // Ensure the counter display shows the correct total number of
504
+ // questions. Without this update the span would retain the
505
+ // server‑rendered value even if the fallback above changed
506
+ // ``this.totalQuestions``.
507
+ if (totalSpan) {
508
+ totalSpan.textContent = this.totalQuestions;
509
+ }
510
  this.isRecording = false;
511
  this.mediaRecorder = null;
512
  this.audioChunks = [];
 
862
  body: JSON.stringify({
863
  answer: answer,
864
  questionIndex: this.currentQuestionIndex,
865
+ current_question: this.currentQuestion,
866
+ job_id: JOB_ID
867
  })
868
  });
869
 
backend/templates/job_detail.html CHANGED
@@ -36,6 +36,17 @@
36
  {% endif %}
37
  </p>
38
  </div>
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  <div style="text-align: center; margin-top: 2rem;">
41
  <p style="margin-bottom: 1rem;">Posted on: {{ job.date_posted.strftime('%B %d, %Y') }}</p>
 
36
  {% endif %}
37
  </p>
38
  </div>
39
+
40
+ <div style="margin-bottom: 2rem;">
41
+ <h3 style="color: var(--primary); margin-bottom: 1rem;">Interview Details</h3>
42
+ <p>
43
+ {% if job.num_questions %}
44
+ {{ job.num_questions }} question{{ 's' if job.num_questions|int != 1 else '' }} in the AI interview
45
+ {% else %}
46
+ Interview length not specified
47
+ {% endif %}
48
+ </p>
49
+ </div>
50
 
51
  <div style="text-align: center; margin-top: 2rem;">
52
  <p style="margin-bottom: 1rem;">Posted on: {{ job.date_posted.strftime('%B %d, %Y') }}</p>
backend/templates/post_job.html CHANGED
@@ -45,6 +45,11 @@
45
  <label for="description">Job Description</label>
46
  <textarea name="description" id="description" class="form-control" rows="5" required placeholder="Describe the responsibilities and requirements for this position"></textarea>
47
  </div>
 
 
 
 
 
48
  <div class="application-actions" style="margin-top: 2rem; text-align: center;">
49
  <button type="submit" class="btn btn-primary">Post Job</button>
50
  </div>
 
45
  <label for="description">Job Description</label>
46
  <textarea name="description" id="description" class="form-control" rows="5" required placeholder="Describe the responsibilities and requirements for this position"></textarea>
47
  </div>
48
+ <div class="form-group">
49
+ <label for="num_questions">Number of Interview Questions</label>
50
+ <input type="number" name="num_questions" id="num_questions" class="form-control" min="1" value="3" required placeholder="e.g. 5">
51
+ <small style="color: #666; display: block; margin-top: 0.25rem;">Specify how many questions the AI interview should ask (minimum 1).</small>
52
+ </div>
53
  <div class="application-actions" style="margin-top: 2rem; text-align: center;">
54
  <button type="submit" class="btn btn-primary">Post Job</button>
55
  </div>