husseinelsaadi commited on
Commit
e0470f3
·
1 Parent(s): 88df9ba
Files changed (1) hide show
  1. backend/services/report_generator.py +33 -37
backend/services/report_generator.py CHANGED
@@ -1,24 +1,3 @@
1
- """Utilities for assembling and exporting interview reports.
2
-
3
- This module provides two primary helpers used by the recruiter dashboard:
4
-
5
- ``generate_llm_interview_report(application)``
6
- Given a candidate's ``Application`` record, assemble a plain‑text report
7
- summarising the interview. Because the interview process currently
8
- executes entirely client‑side and does not persist questions or answers
9
- to the database, this report focuses on the information available on
10
- the server: the candidate's profile, the job requirements and a skills
11
- match score. Should future iterations store richer interview data
12
- server‑side, this function can be extended to include question/answer
13
- transcripts, per‑question scores and LLM‑generated feedback.
14
-
15
- ``create_pdf_report(report_text)``
16
- Convert a multi‑line string into a simple PDF. The implementation
17
- leverages Matplotlib's PDF backend (available by default) to avoid
18
- heavyweight dependencies such as ReportLab or WeasyPrint, which are
19
- absent from the runtime environment. Text is wrapped and split
20
- across multiple pages as necessary.
21
- """
22
 
23
  from __future__ import annotations
24
  import json
@@ -341,33 +320,38 @@ def create_pdf_report(report_text: str) -> BytesIO:
341
 
342
  y_pos -= 0.5
343
 
344
- # Show first 2-3 Q&As on first page
 
 
 
345
  max_qa_on_page1 = min(3, len(report_data['qa_log']))
346
-
347
  for i in range(max_qa_on_page1):
348
  qa = report_data['qa_log'][i]
349
 
350
- # Check if we have space
 
 
 
 
351
  if y_pos < BOTTOM_MARGIN + 2.2:
352
  break
353
 
354
- # Question
355
- question_text = f"Q{i+1}: {qa['question']}"
356
  for line in textwrap.wrap(question_text, width=85):
357
  ax.text(LEFT_MARGIN, y_pos, line,
358
  fontsize=11, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
359
  y_pos -= 0.25
360
  y_pos -= 0.15 # extra spacing after question block
361
 
362
-
363
- # Answer
364
  answer_text = qa['answer']
365
  if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
366
  answer_text = "Prefer not to disclose"
367
 
368
  wrapped_answer = textwrap.fill(answer_text, width=85)
369
  answer_lines = wrapped_answer.split('\n')[:2] # Max 2 lines
370
-
371
  for line in answer_lines:
372
  ax.text(LEFT_MARGIN + 0.3, y_pos, line,
373
  fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
@@ -377,22 +361,27 @@ def create_pdf_report(report_text: str) -> BytesIO:
377
  eval_color = _get_score_color(qa['score'])
378
  ax.text(LEFT_MARGIN + 0.3, y_pos, f"Evaluation: {qa['score']}",
379
  fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
380
-
381
  y_pos -= 0.6
 
 
382
 
383
  # Save first page
384
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
385
  plt.close(fig)
386
 
387
  # === PAGE 2: REMAINING TRANSCRIPT ===
388
- if report_data['qa_log'] and len(report_data['qa_log']) > max_qa_on_page1:
 
 
 
 
389
  _create_transcript_page(
390
  pdf,
391
- report_data['qa_log'][max_qa_on_page1:], # Continue from the next unanswered question
392
  A4_WIDTH, A4_HEIGHT,
393
  LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN,
394
  ACCENT_COLOR, TEXT_COLOR,
395
- start_index=max_qa_on_page1 + 1 # Correct numbering
396
  )
397
 
398
 
@@ -444,8 +433,17 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
444
  data['skills_match']['ratio'] = float(line.split(':')[1].strip().rstrip('%'))
445
  except:
446
  data['skills_match']['ratio'] = 0
447
- elif line.startswith('Score:') and 'skills_match' in str(data):
448
- data['skills_match']['score'] = line.split(':', 1)[1].strip()
 
 
 
 
 
 
 
 
 
449
  elif line.startswith('Question'):
450
  if current_question:
451
  data['qa_log'].append(current_question)
@@ -457,8 +455,6 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
457
  }
458
  elif line.startswith('Answer:') and current_question:
459
  current_question['answer'] = line.split(':', 1)[1].strip()
460
- elif line.startswith('Score:') and current_question:
461
- current_question['score'] = line.split(':', 1)[1].strip()
462
  elif line.startswith('Feedback:') and current_question:
463
  current_question['feedback'] = line.split(':', 1)[1].strip()
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
  from __future__ import annotations
3
  import json
 
320
 
321
  y_pos -= 0.5
322
 
323
+ # Show up to 3 Q&As on the first page. The number actually
324
+ # displayed depends on available space. We track how many
325
+ # questions we render so the remainder can be displayed on
326
+ # subsequent pages without skipping any entries.
327
  max_qa_on_page1 = min(3, len(report_data['qa_log']))
328
+ qa_count_on_page1 = 0
329
  for i in range(max_qa_on_page1):
330
  qa = report_data['qa_log'][i]
331
 
332
+ # Check if we have space for the next Q&A. If not, break
333
+ # early. The 2.2 constant accounts for the approximate
334
+ # vertical space needed for a question, answer, evaluation
335
+ # and some spacing. If insufficient space remains, we
336
+ # stop adding to this page.
337
  if y_pos < BOTTOM_MARGIN + 2.2:
338
  break
339
 
340
+ # Question number starts at 1 on the first page
341
+ question_text = f"Q{qa_count_on_page1 + 1}: {qa['question']}"
342
  for line in textwrap.wrap(question_text, width=85):
343
  ax.text(LEFT_MARGIN, y_pos, line,
344
  fontsize=11, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
345
  y_pos -= 0.25
346
  y_pos -= 0.15 # extra spacing after question block
347
 
348
+ # Answer. Mask salary disclosure if applicable.
 
349
  answer_text = qa['answer']
350
  if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
351
  answer_text = "Prefer not to disclose"
352
 
353
  wrapped_answer = textwrap.fill(answer_text, width=85)
354
  answer_lines = wrapped_answer.split('\n')[:2] # Max 2 lines
 
355
  for line in answer_lines:
356
  ax.text(LEFT_MARGIN + 0.3, y_pos, line,
357
  fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
 
361
  eval_color = _get_score_color(qa['score'])
362
  ax.text(LEFT_MARGIN + 0.3, y_pos, f"Evaluation: {qa['score']}",
363
  fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
 
364
  y_pos -= 0.6
365
+
366
+ qa_count_on_page1 += 1
367
 
368
  # Save first page
369
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
370
  plt.close(fig)
371
 
372
  # === PAGE 2: REMAINING TRANSCRIPT ===
373
+ # Render the remainder of the Q&A log on additional pages. Use
374
+ # qa_count_on_page1 (actual number shown on the first page) rather
375
+ # than the theoretical max_qa_on_page1 so that no entries are
376
+ # inadvertently skipped when the first page runs out of space.
377
+ if report_data['qa_log'] and len(report_data['qa_log']) > qa_count_on_page1:
378
  _create_transcript_page(
379
  pdf,
380
+ report_data['qa_log'][qa_count_on_page1:], # Continue from the next unanswered question
381
  A4_WIDTH, A4_HEIGHT,
382
  LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN,
383
  ACCENT_COLOR, TEXT_COLOR,
384
+ start_index=qa_count_on_page1 + 1 # Correct numbering
385
  )
386
 
387
 
 
433
  data['skills_match']['ratio'] = float(line.split(':')[1].strip().rstrip('%'))
434
  except:
435
  data['skills_match']['ratio'] = 0
436
+ elif line.startswith('Score:'):
437
+ # Distinguish between the overall skills match score and per‑question scores.
438
+ # If no question has been started yet (i.e. current_question is None),
439
+ # interpret this Score line as the skills match score. Otherwise it
440
+ # belongs to the most recent question.
441
+ score_value = line.split(':', 1)[1].strip()
442
+ if current_question is None:
443
+ data['skills_match']['score'] = score_value
444
+ else:
445
+ current_question['score'] = score_value
446
+ continue
447
  elif line.startswith('Question'):
448
  if current_question:
449
  data['qa_log'].append(current_question)
 
455
  }
456
  elif line.startswith('Answer:') and current_question:
457
  current_question['answer'] = line.split(':', 1)[1].strip()
 
 
458
  elif line.startswith('Feedback:') and current_question:
459
  current_question['feedback'] = line.split(':', 1)[1].strip()
460