husseinelsaadi commited on
Commit
aba3be2
·
1 Parent(s): 06654d0
Files changed (1) hide show
  1. backend/services/report_generator.py +261 -146
backend/services/report_generator.py CHANGED
@@ -21,33 +21,34 @@ This module provides two primary helpers used by the recruiter dashboard:
21
  """
22
 
23
  from __future__ import annotations
24
-
25
  import json
26
  from io import BytesIO
27
  import textwrap
28
  from typing import List, Dict, Any, Tuple
29
-
30
  import matplotlib.pyplot as plt
31
  from matplotlib.backends.backend_pdf import PdfPages
32
  import matplotlib.patches as mpatches
33
- from matplotlib.patches import Rectangle, FancyBboxPatch
 
 
 
34
 
35
  def generate_llm_interview_report(application) -> str:
36
  """Generate a human‑readable interview report for a candidate.
37
-
38
  The report includes the candidate's name and email, job details,
39
  application date, a computed skills match summary and placeholder
40
  sections for future enhancements. If server‑side storage of
41
  question/answer pairs is added later, this function can be updated
42
  to incorporate those details.
43
-
44
  Parameters
45
  ----------
46
  application : backend.models.database.Application
47
  The SQLAlchemy Application instance representing the candidate's
48
  job application. Assumed to have related ``job`` and
49
  ``date_applied`` attributes available.
50
-
51
  Returns
52
  -------
53
  str
@@ -64,9 +65,10 @@ def generate_llm_interview_report(application) -> str:
64
  candidate_features = json.loads(application.extracted_features) if application.extracted_features else {}
65
  except Exception:
66
  candidate_features = {}
67
- candidate_skills: List[str] = candidate_features.get('skills', []) or []
68
 
 
69
  job_skills: List[str] = []
 
70
  try:
71
  job_skills = json.loads(application.job.skills) if application.job and application.job.skills else []
72
  except Exception:
@@ -95,12 +97,14 @@ def generate_llm_interview_report(application) -> str:
95
  lines.append('')
96
  lines.append(f'Candidate Name: {application.name}')
97
  lines.append(f'Candidate Email: {application.email}')
 
98
  if application.job:
99
  lines.append(f'Job Applied: {application.job.role}')
100
  lines.append(f'Company: {application.job.company}')
101
  else:
102
  lines.append('Job Applied: N/A')
103
  lines.append('Company: N/A')
 
104
  # Format date_applied if available
105
  try:
106
  date_str = application.date_applied.strftime('%Y-%m-%d') if application.date_applied else 'N/A'
@@ -108,18 +112,21 @@ def generate_llm_interview_report(application) -> str:
108
  date_str = 'N/A'
109
  lines.append(f'Date Applied: {date_str}')
110
  lines.append('')
 
111
  lines.append('Skills Match Summary:')
112
  # Represent required and candidate skills as comma‑separated lists. Use
113
  # title‑case for presentation and handle empty lists gracefully.
114
  formatted_job_skills = ', '.join(job_skills) if job_skills else 'N/A'
115
  formatted_candidate_skills = ', '.join(candidate_skills) if candidate_skills else 'N/A'
116
  formatted_common = ', '.join(sorted(common)) if common else 'None'
 
117
  lines.append(f' Required Skills: {formatted_job_skills}')
118
  lines.append(f' Candidate Skills: {formatted_candidate_skills}')
119
  lines.append(f' Skills in Common: {formatted_common}')
120
  lines.append(f' Match Ratio: {ratio * 100:.0f}%')
121
  lines.append(f' Score: {score_label}')
122
  lines.append('')
 
123
  lines.append('Interview Transcript & Evaluation:')
124
  try:
125
  if application.interview_log:
@@ -127,14 +134,14 @@ def generate_llm_interview_report(application) -> str:
127
  qa_log = json.loads(application.interview_log)
128
  except Exception:
129
  qa_log = []
130
-
131
  if qa_log:
132
  for idx, entry in enumerate(qa_log, 1):
133
  q = entry.get("question", "N/A")
134
  a = entry.get("answer", "N/A")
135
  eval_score = entry.get("evaluation", {}).get("score", "N/A")
136
  eval_feedback = entry.get("evaluation", {}).get("feedback", "N/A")
137
-
138
  lines.append(f"\nQuestion {idx}: {q}")
139
  lines.append(f"Answer: {a}")
140
  lines.append(f"Score: {eval_score}")
@@ -185,137 +192,190 @@ def create_pdf_report(report_text: str) -> BytesIO:
185
  # Current Y position (start from top)
186
  y_pos = A4_HEIGHT - TOP_MARGIN
187
 
188
- # Header background
189
  header_rect = FancyBboxPatch(
190
- (LEFT_MARGIN, y_pos - 1.5), CONTENT_WIDTH, 1.5,
191
- boxstyle="round,pad=0.02",
192
  facecolor='#1e40af',
193
  edgecolor='none'
194
  )
195
  ax.add_patch(header_rect)
196
 
197
- # Company Logo placeholder (circle)
198
- logo_circle = plt.Circle((LEFT_MARGIN + 0.5, y_pos - 0.75), 0.35,
199
- color='white', alpha=0.3)
 
200
  ax.add_patch(logo_circle)
201
 
 
 
 
 
 
202
  # Header text
203
- ax.text(LEFT_MARGIN + 1.2, y_pos - 0.5, 'INTERVIEW REPORT',
204
- fontsize=20, fontweight='bold', color='white',
205
  verticalalignment='center')
206
 
207
- ax.text(LEFT_MARGIN + 1.2, y_pos - 1.0,
208
  f"{report_data['job_role']} • {report_data['company']}",
209
- fontsize=12, color='white', alpha=0.9,
210
  verticalalignment='center')
211
 
212
- y_pos -= 2.0
 
 
 
 
 
 
213
 
214
- # Overall Score Section (Prominent)
215
  overall_score = _calculate_overall_score(report_data)
216
  score_color = _get_score_color(overall_score['label'])
217
 
218
- # Score box
219
  score_box = FancyBboxPatch(
220
- (LEFT_MARGIN, y_pos - 1.2), CONTENT_WIDTH, 1.2,
221
- boxstyle="round,pad=0.05",
222
  facecolor=score_color,
223
  alpha=0.1,
224
  edgecolor=score_color,
225
- linewidth=2
226
  )
227
  ax.add_patch(score_box)
228
 
229
- # Score text
230
- ax.text(LEFT_MARGIN + CONTENT_WIDTH/2, y_pos - 0.3,
231
- 'OVERALL ASSESSMENT',
232
- fontsize=10, color='#6b7280',
233
- horizontalalignment='center')
234
 
235
- ax.text(LEFT_MARGIN + CONTENT_WIDTH/2, y_pos - 0.7,
236
- overall_score['label'].upper(),
237
- fontsize=28, fontweight='bold', color=score_color,
238
- horizontalalignment='center')
239
 
240
- ax.text(LEFT_MARGIN + CONTENT_WIDTH/2, y_pos - 1.0,
241
- f"{overall_score['percentage']}%",
242
- fontsize=16, color=score_color,
243
- horizontalalignment='center')
244
 
245
- y_pos -= 1.7
 
 
 
 
 
 
 
 
246
 
247
  # Candidate Information Section
248
- _add_section_header(ax, LEFT_MARGIN, y_pos, 'Candidate Information', CONTENT_WIDTH)
249
- y_pos -= 0.4
250
 
251
- # Info grid
252
  info_items = [
253
- ('Name', report_data['candidate_name']),
254
- ('Email', report_data['candidate_email']),
255
- ('Position', report_data['job_role']),
256
  ('Company', report_data['company']),
257
- ('Date Applied', report_data['date_applied'])
258
  ]
259
 
 
260
  for i, (label, value) in enumerate(info_items):
261
- y_offset = y_pos - (i * 0.3)
262
- ax.text(LEFT_MARGIN + 0.2, y_offset, f"{label}:",
263
- fontsize=10, color='#6b7280')
264
- ax.text(LEFT_MARGIN + 2.0, y_offset, value,
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  fontsize=10, color='#111827', fontweight='bold')
266
 
267
- y_pos -= 1.8
268
 
269
- # Skills Match Summary
270
- _add_section_header(ax, LEFT_MARGIN, y_pos, 'Skills Analysis', CONTENT_WIDTH)
271
- y_pos -= 0.4
272
 
273
- # Skills match visualization
274
  skills_data = report_data['skills_match']
275
 
276
- # Progress bar for match ratio
277
- bar_x = LEFT_MARGIN + 0.2
278
- bar_y = y_pos - 0.3
279
- bar_width = CONTENT_WIDTH - 0.4
280
- bar_height = 0.3
281
 
282
- # Background bar
283
- bg_bar = Rectangle((bar_x, bar_y), bar_width, bar_height,
284
- facecolor='#e5e7eb', edgecolor='none')
 
 
 
 
 
285
  ax.add_patch(bg_bar)
286
 
287
  # Progress bar
288
- progress_width = bar_width * (skills_data['ratio'] / 100)
289
  progress_color = _get_score_color(skills_data['score'])
290
- progress_bar = Rectangle((bar_x, bar_y), progress_width, bar_height,
291
- facecolor=progress_color, edgecolor='none')
 
 
 
 
292
  ax.add_patch(progress_bar)
293
 
294
- # Percentage text
295
  ax.text(bar_x + bar_width/2, bar_y + bar_height/2,
296
- f"{skills_data['ratio']:.0f}% Match",
297
  fontsize=12, fontweight='bold', color='white',
298
  horizontalalignment='center', verticalalignment='center')
299
 
300
- y_pos -= 0.8
301
 
302
- # Skills details
303
  skills_items = [
304
- ('Required Skills', skills_data['required']),
305
- ('Candidate Skills', skills_data['candidate']),
306
- ('Matching Skills', skills_data['common'])
307
  ]
308
 
309
- for i, (label, value) in enumerate(skills_items):
310
- y_offset = y_pos - (i * 0.4)
311
- ax.text(LEFT_MARGIN + 0.2, y_offset, f"{label}:",
312
- fontsize=9, color='#6b7280')
313
- # Wrap long skill lists
314
- wrapped_value = textwrap.fill(value, width=60)
315
- lines = wrapped_value.split('\n')
316
- for j, line in enumerate(lines):
317
- ax.text(LEFT_MARGIN + 0.2, y_offset - 0.2 - (j * 0.2), line,
318
- fontsize=9, color='#374151', style='italic')
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
  # Save first page
321
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
@@ -353,6 +413,7 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
353
 
354
  for line in lines:
355
  line = line.strip()
 
356
  if line.startswith('Candidate Name:'):
357
  data['candidate_name'] = line.split(':', 1)[1].strip()
358
  elif line.startswith('Candidate Email:'):
@@ -407,13 +468,13 @@ def _calculate_overall_score(report_data: Dict[str, Any]) -> Dict[str, Any]:
407
  qa_scores = []
408
  for qa in report_data['qa_log']:
409
  score_text = qa['score'].lower()
410
- if 'excellent' in score_text or '5' in score_text:
411
  qa_scores.append(1.0)
412
- elif 'good' in score_text or '4' in score_text:
413
  qa_scores.append(0.8)
414
- elif 'satisfactory' in score_text or '3' in score_text:
415
  qa_scores.append(0.6)
416
- elif 'needs improvement' in score_text or '2' in score_text:
417
  qa_scores.append(0.4)
418
  else:
419
  qa_scores.append(0.2)
@@ -426,9 +487,9 @@ def _calculate_overall_score(report_data: Dict[str, Any]) -> Dict[str, Any]:
426
 
427
  if overall >= 0.8:
428
  label = 'Excellent'
429
- elif overall >= 0.6:
430
  label = 'Good'
431
- elif overall >= 0.4:
432
  label = 'Satisfactory'
433
  else:
434
  label = 'Needs Improvement'
@@ -442,35 +503,54 @@ def _get_score_color(score_label: str) -> str:
442
  if 'excellent' in score_label:
443
  return '#059669' # Green
444
  elif 'good' in score_label:
445
- return '#3b82f6' # Blue
446
  elif 'medium' in score_label or 'satisfactory' in score_label:
447
- return '#f59e0b' # Amber
448
  else:
449
- return '#ef4444' # Red
 
 
 
 
 
 
 
 
 
 
 
450
 
451
 
452
  def _add_section_header(ax, x: float, y: float, title: str, width: float):
453
- """Add a section header with separator line."""
 
 
 
 
 
 
 
 
 
 
454
  # Title
455
- ax.text(x, y, title.upper(), fontsize=12, fontweight='bold',
456
- color='#1e293b')
457
 
458
- # Separator line
459
- line = plt.Line2D([x, x + width], [y - 0.15, y - 0.15],
460
- color='#e5e7eb', linewidth=1)
461
  ax.add_line(line)
462
 
463
 
464
- def _create_transcript_pages(pdf, qa_log: List[Dict],
465
- page_width: float, page_height: float,
466
  left_margin: float, right_margin: float,
467
  top_margin: float, bottom_margin: float):
468
- """Create pages for interview transcript."""
469
  content_width = page_width - left_margin - right_margin
470
- wrapper = textwrap.TextWrapper(width=80)
471
 
472
  # Group questions for pagination
473
- questions_per_page = 3
474
  total_pages = (len(qa_log) + questions_per_page - 1) // questions_per_page
475
 
476
  for page_num in range(total_pages):
@@ -483,14 +563,24 @@ def _create_transcript_pages(pdf, qa_log: List[Dict],
483
 
484
  # Page header
485
  y_pos = page_height - top_margin
486
- ax.text(left_margin, y_pos, 'INTERVIEW TRANSCRIPT',
487
- fontsize=14, fontweight='bold', color='#1e293b')
 
 
 
 
 
 
 
 
 
 
488
 
489
  # Page number
490
- ax.text(page_width - right_margin, y_pos, f'Page {page_num + 2}',
491
- fontsize=9, color='#9ca3af', horizontalalignment='right')
492
 
493
- y_pos -= 0.5
494
 
495
  # Questions for this page
496
  start_idx = page_num * questions_per_page
@@ -499,45 +589,70 @@ def _create_transcript_pages(pdf, qa_log: List[Dict],
499
  for i in range(start_idx, end_idx):
500
  qa = qa_log[i]
501
 
502
- # Question box
503
  q_box = FancyBboxPatch(
504
- (left_margin, y_pos - 0.8), content_width, 0.8,
505
- boxstyle="round,pad=0.02",
506
  facecolor='#eff6ff',
507
  edgecolor='#3b82f6',
508
- linewidth=1
509
  )
510
  ax.add_patch(q_box)
511
 
512
- # Question number and text
513
- ax.text(left_margin + 0.1, y_pos - 0.2, f'Q{i+1}.',
514
- fontsize=14, fontweight='bold', color='#1e40af')
 
 
 
 
 
 
 
 
 
515
 
516
- # Wrap question text
517
  q_wrapped = wrapper.wrap(qa['question'])
518
- for j, line in enumerate(q_wrapped[:2]): # Max 2 lines
519
- ax.text(left_margin + 0.5, y_pos - 0.2 - (j * 0.25), line,
520
  fontsize=11, fontweight='bold', color='#1e293b')
521
 
522
- y_pos -= 1.2
523
 
524
- # Answer
525
- ax.text(left_margin + 0.2, y_pos, 'Answer:',
526
- fontsize=10, fontweight='bold', color='#6b7280')
 
 
 
 
 
 
 
 
 
527
 
528
  a_wrapped = wrapper.wrap(qa['answer'])
529
- for j, line in enumerate(a_wrapped[:3]): # Max 3 lines
530
- ax.text(left_margin + 0.2, y_pos - 0.3 - (j * 0.2), line,
531
  fontsize=10, color='#374151')
532
 
533
- y_pos -= 1.0
534
 
535
- # Score and Feedback row
536
- score_color = _get_score_color(qa['score'])
 
 
 
 
 
 
 
537
 
538
  # Score badge
 
539
  score_badge = FancyBboxPatch(
540
- (left_margin + 0.2, y_pos - 0.3), 1.5, 0.3,
541
  boxstyle="round,pad=0.02",
542
  facecolor=score_color,
543
  alpha=0.2,
@@ -546,28 +661,29 @@ def _create_transcript_pages(pdf, qa_log: List[Dict],
546
  )
547
  ax.add_patch(score_badge)
548
 
549
- ax.text(left_margin + 0.95, y_pos - 0.15, qa['score'],
550
- fontsize=9, fontweight='bold', color=score_color,
551
  horizontalalignment='center', verticalalignment='center')
552
 
553
  # Feedback
554
  if qa['feedback'] and qa['feedback'] != 'N/A':
555
- ax.text(left_margin + 2.0, y_pos, 'Feedback:',
556
- fontsize=9, style='italic', color='#6b7280')
557
 
558
  f_wrapped = wrapper.wrap(qa['feedback'])
559
  for j, line in enumerate(f_wrapped[:2]): # Max 2 lines
560
- ax.text(left_margin + 2.0, y_pos - 0.25 - (j * 0.2), line,
561
- fontsize=9, style='italic', color='#6b7280')
562
 
563
- y_pos -= 1.5
564
 
565
  # Add separator between questions (except last)
566
  if i < end_idx - 1:
567
- line = plt.Line2D([left_margin, left_margin + content_width],
568
- [y_pos + 0.5, y_pos + 0.5],
569
- color='#e5e7eb', linewidth=0.5)
570
- ax.add_line(line)
 
571
 
572
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
573
  plt.close(fig)
@@ -587,7 +703,7 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
587
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
588
  from reportlab.lib.colors import HexColor
589
  from reportlab.lib.enums import TA_LEFT, TA_CENTER
590
-
591
  buffer = BytesIO()
592
 
593
  # Create document with consistent margins
@@ -670,14 +786,12 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
670
 
671
  for line in lines:
672
  stripped = line.strip()
673
-
674
  if not stripped:
675
  story.append(Spacer(1, 6))
676
  elif 'Interview Report' in stripped:
677
  story.append(Paragraph(stripped, title_style))
678
  story.append(Spacer(1, 12))
679
- elif any(stripped.startswith(x) for x in ['Candidate Name:', 'Candidate Email:',
680
- 'Job Applied:', 'Company:', 'Date Applied:']):
681
  story.append(Paragraph(stripped, header_style))
682
  elif stripped.startswith('Skills Match Summary:') or stripped.startswith('Interview Transcript'):
683
  story.append(Spacer(1, 12))
@@ -693,7 +807,7 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
693
  story.append(Paragraph(stripped, feedback_style))
694
  else:
695
  # Regular text with proper indentation for sub-items
696
- if stripped.startswith(' '):
697
  indent_style = ParagraphStyle(
698
  'Indented',
699
  parent=styles['Normal'],
@@ -714,5 +828,6 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
714
  # Fallback to matplotlib version if reportlab not available
715
  print("Reportlab not installed. Using matplotlib version.")
716
  return create_pdf_report(report_text)
717
-
718
- __all__ = ['generate_llm_interview_report', 'create_pdf_report']
 
 
21
  """
22
 
23
  from __future__ import annotations
 
24
  import json
25
  from io import BytesIO
26
  import textwrap
27
  from typing import List, Dict, Any, Tuple
 
28
  import matplotlib.pyplot as plt
29
  from matplotlib.backends.backend_pdf import PdfPages
30
  import matplotlib.patches as mpatches
31
+ from matplotlib.patches import Rectangle, FancyBboxPatch, Circle
32
+ import matplotlib.dates as mdates
33
+ from datetime import datetime
34
+
35
 
36
  def generate_llm_interview_report(application) -> str:
37
  """Generate a human‑readable interview report for a candidate.
38
+
39
  The report includes the candidate's name and email, job details,
40
  application date, a computed skills match summary and placeholder
41
  sections for future enhancements. If server‑side storage of
42
  question/answer pairs is added later, this function can be updated
43
  to incorporate those details.
44
+
45
  Parameters
46
  ----------
47
  application : backend.models.database.Application
48
  The SQLAlchemy Application instance representing the candidate's
49
  job application. Assumed to have related ``job`` and
50
  ``date_applied`` attributes available.
51
+
52
  Returns
53
  -------
54
  str
 
65
  candidate_features = json.loads(application.extracted_features) if application.extracted_features else {}
66
  except Exception:
67
  candidate_features = {}
 
68
 
69
+ candidate_skills: List[str] = candidate_features.get('skills', []) or []
70
  job_skills: List[str] = []
71
+
72
  try:
73
  job_skills = json.loads(application.job.skills) if application.job and application.job.skills else []
74
  except Exception:
 
97
  lines.append('')
98
  lines.append(f'Candidate Name: {application.name}')
99
  lines.append(f'Candidate Email: {application.email}')
100
+
101
  if application.job:
102
  lines.append(f'Job Applied: {application.job.role}')
103
  lines.append(f'Company: {application.job.company}')
104
  else:
105
  lines.append('Job Applied: N/A')
106
  lines.append('Company: N/A')
107
+
108
  # Format date_applied if available
109
  try:
110
  date_str = application.date_applied.strftime('%Y-%m-%d') if application.date_applied else 'N/A'
 
112
  date_str = 'N/A'
113
  lines.append(f'Date Applied: {date_str}')
114
  lines.append('')
115
+
116
  lines.append('Skills Match Summary:')
117
  # Represent required and candidate skills as comma‑separated lists. Use
118
  # title‑case for presentation and handle empty lists gracefully.
119
  formatted_job_skills = ', '.join(job_skills) if job_skills else 'N/A'
120
  formatted_candidate_skills = ', '.join(candidate_skills) if candidate_skills else 'N/A'
121
  formatted_common = ', '.join(sorted(common)) if common else 'None'
122
+
123
  lines.append(f' Required Skills: {formatted_job_skills}')
124
  lines.append(f' Candidate Skills: {formatted_candidate_skills}')
125
  lines.append(f' Skills in Common: {formatted_common}')
126
  lines.append(f' Match Ratio: {ratio * 100:.0f}%')
127
  lines.append(f' Score: {score_label}')
128
  lines.append('')
129
+
130
  lines.append('Interview Transcript & Evaluation:')
131
  try:
132
  if application.interview_log:
 
134
  qa_log = json.loads(application.interview_log)
135
  except Exception:
136
  qa_log = []
137
+
138
  if qa_log:
139
  for idx, entry in enumerate(qa_log, 1):
140
  q = entry.get("question", "N/A")
141
  a = entry.get("answer", "N/A")
142
  eval_score = entry.get("evaluation", {}).get("score", "N/A")
143
  eval_feedback = entry.get("evaluation", {}).get("feedback", "N/A")
144
+
145
  lines.append(f"\nQuestion {idx}: {q}")
146
  lines.append(f"Answer: {a}")
147
  lines.append(f"Score: {eval_score}")
 
192
  # Current Y position (start from top)
193
  y_pos = A4_HEIGHT - TOP_MARGIN
194
 
195
+ # Professional Header with gradient-like effect
196
  header_rect = FancyBboxPatch(
197
+ (LEFT_MARGIN, y_pos - 1.8), CONTENT_WIDTH, 1.8,
198
+ boxstyle="round,pad=0.05",
199
  facecolor='#1e40af',
200
  edgecolor='none'
201
  )
202
  ax.add_patch(header_rect)
203
 
204
+ # Company Logo placeholder (professional circle)
205
+ logo_circle = Circle((LEFT_MARGIN + 0.6, y_pos - 0.9), 0.4,
206
+ color='white', alpha=0.15, linewidth=2,
207
+ edgecolor='white')
208
  ax.add_patch(logo_circle)
209
 
210
+ # Logo text
211
+ ax.text(LEFT_MARGIN + 0.6, y_pos - 0.9, 'IR',
212
+ fontsize=16, fontweight='bold', color='white',
213
+ horizontalalignment='center', verticalalignment='center')
214
+
215
  # Header text
216
+ ax.text(LEFT_MARGIN + 1.4, y_pos - 0.5, 'INTERVIEW ASSESSMENT REPORT',
217
+ fontsize=18, fontweight='bold', color='white',
218
  verticalalignment='center')
219
 
220
+ ax.text(LEFT_MARGIN + 1.4, y_pos - 0.9,
221
  f"{report_data['job_role']} • {report_data['company']}",
222
+ fontsize=11, color='white', alpha=0.9,
223
  verticalalignment='center')
224
 
225
+ # Date
226
+ current_date = datetime.now().strftime('%B %d, %Y')
227
+ ax.text(A4_WIDTH - RIGHT_MARGIN, y_pos - 1.4, f'Generated: {current_date}',
228
+ fontsize=9, color='white', alpha=0.8,
229
+ horizontalalignment='right')
230
+
231
+ y_pos -= 2.2
232
 
233
+ # Overall Score Section (Very Prominent)
234
  overall_score = _calculate_overall_score(report_data)
235
  score_color = _get_score_color(overall_score['label'])
236
 
237
+ # Large score display box
238
  score_box = FancyBboxPatch(
239
+ (LEFT_MARGIN, y_pos - 1.5), CONTENT_WIDTH, 1.5,
240
+ boxstyle="round,pad=0.08",
241
  facecolor=score_color,
242
  alpha=0.1,
243
  edgecolor=score_color,
244
+ linewidth=3
245
  )
246
  ax.add_patch(score_box)
247
 
248
+ # Score circle indicator
249
+ score_circle = Circle((LEFT_MARGIN + 0.8, y_pos - 0.75), 0.5,
250
+ facecolor=score_color, alpha=0.2,
251
+ edgecolor=score_color, linewidth=3)
252
+ ax.add_patch(score_circle)
253
 
254
+ # Score percentage in circle
255
+ ax.text(LEFT_MARGIN + 0.8, y_pos - 0.75, f"{overall_score['percentage']:.0f}%",
256
+ fontsize=20, fontweight='bold', color=score_color,
257
+ horizontalalignment='center', verticalalignment='center')
258
 
259
+ # Score label
260
+ ax.text(LEFT_MARGIN + 1.8, y_pos - 0.4, 'OVERALL ASSESSMENT',
261
+ fontsize=10, color='#6b7280', fontweight='bold')
 
262
 
263
+ ax.text(LEFT_MARGIN + 1.8, y_pos - 0.8, overall_score['label'].upper(),
264
+ fontsize=24, fontweight='bold', color=score_color)
265
+
266
+ # Score description
267
+ score_desc = _get_score_description(overall_score['label'])
268
+ ax.text(LEFT_MARGIN + 1.8, y_pos - 1.1, score_desc,
269
+ fontsize=10, color='#6b7280', style='italic')
270
+
271
+ y_pos -= 2.0
272
 
273
  # Candidate Information Section
274
+ _add_section_header(ax, LEFT_MARGIN, y_pos, 'CANDIDATE PROFILE', CONTENT_WIDTH)
275
+ y_pos -= 0.5
276
 
277
+ # Professional info grid with better spacing
278
  info_items = [
279
+ ('Full Name', report_data['candidate_name']),
280
+ ('Email Address', report_data['candidate_email']),
281
+ ('Applied Position', report_data['job_role']),
282
  ('Company', report_data['company']),
283
+ ('Application Date', report_data['date_applied'])
284
  ]
285
 
286
+ # Create info boxes
287
  for i, (label, value) in enumerate(info_items):
288
+ y_offset = y_pos - (i * 0.35)
289
+
290
+ # Label
291
+ ax.text(LEFT_MARGIN + 0.1, y_offset, f"{label}:",
292
+ fontsize=9, color='#6b7280', fontweight='bold')
293
+
294
+ # Value with background
295
+ value_rect = FancyBboxPatch(
296
+ (LEFT_MARGIN + 2.2, y_offset - 0.1), CONTENT_WIDTH - 2.3, 0.25,
297
+ boxstyle="round,pad=0.02",
298
+ facecolor='#f8fafc',
299
+ edgecolor='#e2e8f0',
300
+ linewidth=0.5
301
+ )
302
+ ax.add_patch(value_rect)
303
+
304
+ ax.text(LEFT_MARGIN + 2.3, y_offset, value,
305
  fontsize=10, color='#111827', fontweight='bold')
306
 
307
+ y_pos -= 2.2
308
 
309
+ # Skills Analysis Section
310
+ _add_section_header(ax, LEFT_MARGIN, y_pos, 'SKILLS ANALYSIS', CONTENT_WIDTH)
311
+ y_pos -= 0.5
312
 
 
313
  skills_data = report_data['skills_match']
314
 
315
+ # Skills match visualization with better design
316
+ bar_x = LEFT_MARGIN + 0.1
317
+ bar_y = y_pos - 0.4
318
+ bar_width = CONTENT_WIDTH - 0.2
319
+ bar_height = 0.4
320
 
321
+ # Background bar with rounded corners
322
+ bg_bar = FancyBboxPatch(
323
+ (bar_x, bar_y), bar_width, bar_height,
324
+ boxstyle="round,pad=0.02",
325
+ facecolor='#f1f5f9',
326
+ edgecolor='#cbd5e1',
327
+ linewidth=1
328
+ )
329
  ax.add_patch(bg_bar)
330
 
331
  # Progress bar
332
+ progress_width = max(0.3, bar_width * (skills_data['ratio'] / 100)) # Minimum visible width
333
  progress_color = _get_score_color(skills_data['score'])
334
+ progress_bar = FancyBboxPatch(
335
+ (bar_x, bar_y), progress_width, bar_height,
336
+ boxstyle="round,pad=0.02",
337
+ facecolor=progress_color,
338
+ edgecolor='none'
339
+ )
340
  ax.add_patch(progress_bar)
341
 
342
+ # Percentage text with better positioning
343
  ax.text(bar_x + bar_width/2, bar_y + bar_height/2,
344
+ f"{skills_data['ratio']:.0f}% SKILLS MATCH",
345
  fontsize=12, fontweight='bold', color='white',
346
  horizontalalignment='center', verticalalignment='center')
347
 
348
+ y_pos -= 0.9
349
 
350
+ # Skills details with better formatting
351
  skills_items = [
352
+ ('Required Skills', skills_data['required'], '#dc2626'),
353
+ ('Candidate Skills', skills_data['candidate'], '#2563eb'),
354
+ ('Matching Skills', skills_data['common'], '#059669')
355
  ]
356
 
357
+ for i, (label, value, color) in enumerate(skills_items):
358
+ y_offset = y_pos - (i * 0.6)
359
+
360
+ # Color indicator
361
+ indicator = Circle((LEFT_MARGIN + 0.15, y_offset), 0.08,
362
+ facecolor=color, edgecolor='none')
363
+ ax.add_patch(indicator)
364
+
365
+ # Label
366
+ ax.text(LEFT_MARGIN + 0.35, y_offset, f"{label}:",
367
+ fontsize=10, color='#374151', fontweight='bold')
368
+
369
+ # Wrap and display skills
370
+ if value and value != 'N/A':
371
+ wrapped_value = textwrap.fill(value, width=65)
372
+ lines = wrapped_value.split('\n')
373
+ for j, line in enumerate(lines[:2]): # Max 2 lines
374
+ ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25 - (j * 0.2), line,
375
+ fontsize=9, color='#6b7280')
376
+ else:
377
+ ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25, 'None specified',
378
+ fontsize=9, color='#9ca3af', style='italic')
379
 
380
  # Save first page
381
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
 
413
 
414
  for line in lines:
415
  line = line.strip()
416
+
417
  if line.startswith('Candidate Name:'):
418
  data['candidate_name'] = line.split(':', 1)[1].strip()
419
  elif line.startswith('Candidate Email:'):
 
468
  qa_scores = []
469
  for qa in report_data['qa_log']:
470
  score_text = qa['score'].lower()
471
+ if 'excellent' in score_text or '5' in score_text or '10' in score_text:
472
  qa_scores.append(1.0)
473
+ elif 'good' in score_text or '4' in score_text or '8' in score_text or '9' in score_text:
474
  qa_scores.append(0.8)
475
+ elif 'satisfactory' in score_text or 'medium' in score_text or '3' in score_text or '6' in score_text or '7' in score_text:
476
  qa_scores.append(0.6)
477
+ elif 'needs improvement' in score_text or 'poor' in score_text or '2' in score_text or '4' in score_text or '5' in score_text:
478
  qa_scores.append(0.4)
479
  else:
480
  qa_scores.append(0.2)
 
487
 
488
  if overall >= 0.8:
489
  label = 'Excellent'
490
+ elif overall >= 0.65:
491
  label = 'Good'
492
+ elif overall >= 0.45:
493
  label = 'Satisfactory'
494
  else:
495
  label = 'Needs Improvement'
 
503
  if 'excellent' in score_label:
504
  return '#059669' # Green
505
  elif 'good' in score_label:
506
+ return '#2563eb' # Blue
507
  elif 'medium' in score_label or 'satisfactory' in score_label:
508
+ return '#d97706' # Orange
509
  else:
510
+ return '#dc2626' # Red
511
+
512
+
513
+ def _get_score_description(score_label: str) -> str:
514
+ """Get description based on score label."""
515
+ descriptions = {
516
+ 'Excellent': 'Outstanding candidate with strong alignment',
517
+ 'Good': 'Solid candidate with good potential',
518
+ 'Satisfactory': 'Adequate candidate with room for growth',
519
+ 'Needs Improvement': 'Candidate requires significant development'
520
+ }
521
+ return descriptions.get(score_label, 'Assessment completed')
522
 
523
 
524
  def _add_section_header(ax, x: float, y: float, title: str, width: float):
525
+ """Add a professional section header with separator line."""
526
+ # Background for header
527
+ header_bg = FancyBboxPatch(
528
+ (x - 0.05, y - 0.05), width + 0.1, 0.35,
529
+ boxstyle="round,pad=0.02",
530
+ facecolor='#f8fafc',
531
+ edgecolor='#e2e8f0',
532
+ linewidth=1
533
+ )
534
+ ax.add_patch(header_bg)
535
+
536
  # Title
537
+ ax.text(x + 0.1, y + 0.1, title, fontsize=12, fontweight='bold', color='#1e293b')
 
538
 
539
+ # Decorative line
540
+ line = plt.Line2D([x + 0.1, x + width - 0.1], [y - 0.15, y - 0.15],
541
+ color='#3b82f6', linewidth=2)
542
  ax.add_line(line)
543
 
544
 
545
+ def _create_transcript_pages(pdf, qa_log: List[Dict], page_width: float, page_height: float,
 
546
  left_margin: float, right_margin: float,
547
  top_margin: float, bottom_margin: float):
548
+ """Create professional pages for interview transcript."""
549
  content_width = page_width - left_margin - right_margin
550
+ wrapper = textwrap.TextWrapper(width=75)
551
 
552
  # Group questions for pagination
553
+ questions_per_page = 2 # Reduced for better spacing
554
  total_pages = (len(qa_log) + questions_per_page - 1) // questions_per_page
555
 
556
  for page_num in range(total_pages):
 
563
 
564
  # Page header
565
  y_pos = page_height - top_margin
566
+
567
+ # Header background
568
+ header_rect = FancyBboxPatch(
569
+ (left_margin, y_pos - 0.6), content_width, 0.6,
570
+ boxstyle="round,pad=0.02",
571
+ facecolor='#1e40af',
572
+ edgecolor='none'
573
+ )
574
+ ax.add_patch(header_rect)
575
+
576
+ ax.text(left_margin + 0.2, y_pos - 0.3, 'INTERVIEW TRANSCRIPT',
577
+ fontsize=14, fontweight='bold', color='white')
578
 
579
  # Page number
580
+ ax.text(page_width - right_margin - 0.2, y_pos - 0.3, f'Page {page_num + 2}',
581
+ fontsize=10, color='white', horizontalalignment='right')
582
 
583
+ y_pos -= 1.0
584
 
585
  # Questions for this page
586
  start_idx = page_num * questions_per_page
 
589
  for i in range(start_idx, end_idx):
590
  qa = qa_log[i]
591
 
592
+ # Question section
593
  q_box = FancyBboxPatch(
594
+ (left_margin, y_pos - 1.0), content_width, 1.0,
595
+ boxstyle="round,pad=0.05",
596
  facecolor='#eff6ff',
597
  edgecolor='#3b82f6',
598
+ linewidth=2
599
  )
600
  ax.add_patch(q_box)
601
 
602
+ # Question number badge
603
+ q_badge = Circle((left_margin + 0.4, y_pos - 0.5), 0.2,
604
+ facecolor='#3b82f6', edgecolor='white', linewidth=2)
605
+ ax.add_patch(q_badge)
606
+
607
+ ax.text(left_margin + 0.4, y_pos - 0.5, f'{i+1}',
608
+ fontsize=12, fontweight='bold', color='white',
609
+ horizontalalignment='center', verticalalignment='center')
610
+
611
+ # Question text
612
+ ax.text(left_margin + 0.8, y_pos - 0.3, 'QUESTION',
613
+ fontsize=9, fontweight='bold', color='#1e40af')
614
 
 
615
  q_wrapped = wrapper.wrap(qa['question'])
616
+ for j, line in enumerate(q_wrapped[:3]): # Max 3 lines
617
+ ax.text(left_margin + 0.8, y_pos - 0.5 - (j * 0.15), line,
618
  fontsize=11, fontweight='bold', color='#1e293b')
619
 
620
+ y_pos -= 1.4
621
 
622
+ # Answer section
623
+ answer_box = FancyBboxPatch(
624
+ (left_margin + 0.2, y_pos - 1.2), content_width - 0.4, 1.2,
625
+ boxstyle="round,pad=0.05",
626
+ facecolor='#f9fafb',
627
+ edgecolor='#d1d5db',
628
+ linewidth=1
629
+ )
630
+ ax.add_patch(answer_box)
631
+
632
+ ax.text(left_margin + 0.4, y_pos - 0.2, 'CANDIDATE RESPONSE',
633
+ fontsize=9, fontweight='bold', color='#6b7280')
634
 
635
  a_wrapped = wrapper.wrap(qa['answer'])
636
+ for j, line in enumerate(a_wrapped[:4]): # Max 4 lines
637
+ ax.text(left_margin + 0.4, y_pos - 0.4 - (j * 0.15), line,
638
  fontsize=10, color='#374151')
639
 
640
+ y_pos -= 1.6
641
 
642
+ # Evaluation section
643
+ eval_box = FancyBboxPatch(
644
+ (left_margin + 0.4, y_pos - 0.8), content_width - 0.8, 0.8,
645
+ boxstyle="round,pad=0.05",
646
+ facecolor='#fefefe',
647
+ edgecolor='#e5e7eb',
648
+ linewidth=1
649
+ )
650
+ ax.add_patch(eval_box)
651
 
652
  # Score badge
653
+ score_color = _get_score_color(qa['score'])
654
  score_badge = FancyBboxPatch(
655
+ (left_margin + 0.6, y_pos - 0.35), 1.2, 0.25,
656
  boxstyle="round,pad=0.02",
657
  facecolor=score_color,
658
  alpha=0.2,
 
661
  )
662
  ax.add_patch(score_badge)
663
 
664
+ ax.text(left_margin + 1.2, y_pos - 0.225, qa['score'],
665
+ fontsize=10, fontweight='bold', color=score_color,
666
  horizontalalignment='center', verticalalignment='center')
667
 
668
  # Feedback
669
  if qa['feedback'] and qa['feedback'] != 'N/A':
670
+ ax.text(left_margin + 2.2, y_pos - 0.15, 'Feedback:',
671
+ fontsize=9, fontweight='bold', color='#6b7280')
672
 
673
  f_wrapped = wrapper.wrap(qa['feedback'])
674
  for j, line in enumerate(f_wrapped[:2]): # Max 2 lines
675
+ ax.text(left_margin + 2.2, y_pos - 0.35 - (j * 0.15), line,
676
+ fontsize=9, color='#6b7280', style='italic')
677
 
678
+ y_pos -= 1.2
679
 
680
  # Add separator between questions (except last)
681
  if i < end_idx - 1:
682
+ separator = plt.Line2D([left_margin + 1, left_margin + content_width - 1],
683
+ [y_pos + 0.3, y_pos + 0.3],
684
+ color='#e5e7eb', linewidth=1, linestyle='--')
685
+ ax.add_line(separator)
686
+ y_pos -= 0.3
687
 
688
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
689
  plt.close(fig)
 
703
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
704
  from reportlab.lib.colors import HexColor
705
  from reportlab.lib.enums import TA_LEFT, TA_CENTER
706
+
707
  buffer = BytesIO()
708
 
709
  # Create document with consistent margins
 
786
 
787
  for line in lines:
788
  stripped = line.strip()
 
789
  if not stripped:
790
  story.append(Spacer(1, 6))
791
  elif 'Interview Report' in stripped:
792
  story.append(Paragraph(stripped, title_style))
793
  story.append(Spacer(1, 12))
794
+ elif any(stripped.startswith(x) for x in ['Candidate Name:', 'Candidate Email:', 'Job Applied:', 'Company:', 'Date Applied:']):
 
795
  story.append(Paragraph(stripped, header_style))
796
  elif stripped.startswith('Skills Match Summary:') or stripped.startswith('Interview Transcript'):
797
  story.append(Spacer(1, 12))
 
807
  story.append(Paragraph(stripped, feedback_style))
808
  else:
809
  # Regular text with proper indentation for sub-items
810
+ if stripped.startswith(' '):
811
  indent_style = ParagraphStyle(
812
  'Indented',
813
  parent=styles['Normal'],
 
828
  # Fallback to matplotlib version if reportlab not available
829
  print("Reportlab not installed. Using matplotlib version.")
830
  return create_pdf_report(report_text)
831
+
832
+
833
+ __all__ = ['generate_llm_interview_report', 'create_pdf_report']