husseinelsaadi commited on
Commit
b822cf0
·
1 Parent(s): 194e7a7
Files changed (1) hide show
  1. backend/services/report_generator.py +211 -500
backend/services/report_generator.py CHANGED
@@ -28,8 +28,7 @@ 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
 
@@ -68,7 +67,6 @@ def generate_llm_interview_report(application) -> str:
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,7 +95,6 @@ def generate_llm_interview_report(application) -> str:
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}')
@@ -119,7 +116,7 @@ def generate_llm_interview_report(application) -> str:
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}')
@@ -172,19 +169,25 @@ def create_pdf_report(report_text: str) -> BytesIO:
172
  # Margins in inches
173
  LEFT_MARGIN = 0.75
174
  RIGHT_MARGIN = 0.75
175
- TOP_MARGIN = 1.0
176
- BOTTOM_MARGIN = 1.0
177
 
178
  # Calculate content area
179
  CONTENT_WIDTH = A4_WIDTH - LEFT_MARGIN - RIGHT_MARGIN
180
  CONTENT_HEIGHT = A4_HEIGHT - TOP_MARGIN - BOTTOM_MARGIN
181
 
 
 
 
 
 
 
182
  # Parse report data
183
  report_data = _parse_report_text(report_text)
184
 
185
  # Create PDF
186
  with PdfPages(buffer) as pdf:
187
- # Page 1: Header and Summary
188
  fig = plt.figure(figsize=(A4_WIDTH, A4_HEIGHT))
189
  fig.patch.set_facecolor('white')
190
 
@@ -197,199 +200,181 @@ def create_pdf_report(report_text: str) -> BytesIO:
197
  # Current Y position (start from top)
198
  y_pos = A4_HEIGHT - TOP_MARGIN
199
 
200
- # Professional Header with gradient-like effect
201
- header_rect = FancyBboxPatch(
202
- (LEFT_MARGIN, y_pos - 1.8), CONTENT_WIDTH, 1.8,
203
- boxstyle="round,pad=0.05",
204
- facecolor='#1e40af',
205
- edgecolor='none'
206
- )
207
- ax.add_patch(header_rect)
208
-
209
- # Company Logo placeholder (professional circle)
210
- logo_circle = Circle((LEFT_MARGIN + 0.6, y_pos - 0.9), 0.4,
211
- color='white', alpha=0.15, linewidth=2,
212
- edgecolor='white')
213
- ax.add_patch(logo_circle)
214
-
215
- # Logo text
216
- ax.text(LEFT_MARGIN + 0.6, y_pos - 0.9, 'IR',
217
- fontsize=16, fontweight='bold', color='white',
218
- horizontalalignment='center', verticalalignment='center')
219
-
220
- # Header text
221
- ax.text(LEFT_MARGIN + 1.4, y_pos - 0.5, 'INTERVIEW ASSESSMENT REPORT',
222
- fontsize=18, fontweight='bold', color='white',
223
- verticalalignment='center')
224
-
225
- ax.text(LEFT_MARGIN + 1.4, y_pos - 0.9,
226
- f"{report_data['job_role']} • {report_data['company']}",
227
- fontsize=11, color='white', alpha=0.9,
228
- verticalalignment='center')
229
 
230
  # Date
231
  current_date = datetime.now().strftime('%B %d, %Y')
232
- ax.text(A4_WIDTH - RIGHT_MARGIN, y_pos - 1.4, f'Generated: {current_date}',
233
- fontsize=9, color='white', alpha=0.8,
234
  horizontalalignment='right')
235
 
236
- y_pos -= 2.2
237
 
238
- # Overall Score Section (Very Prominent)
 
239
  overall_score = _calculate_overall_score(report_data)
240
  score_color = _get_score_color(overall_score['label'])
241
 
242
- # Large score display box
243
- score_box = FancyBboxPatch(
244
- (LEFT_MARGIN, y_pos - 1.5), CONTENT_WIDTH, 1.5,
245
- boxstyle="round,pad=0.08",
246
- facecolor=score_color,
247
- alpha=0.1,
248
- edgecolor=score_color,
249
- linewidth=3
 
 
 
 
250
  )
251
- ax.add_patch(score_box)
252
 
253
- # Score circle indicator
254
- score_circle = Circle((LEFT_MARGIN + 0.8, y_pos - 0.75), 0.5,
255
- facecolor=score_color, alpha=0.2,
256
- edgecolor=score_color, linewidth=3)
257
- ax.add_patch(score_circle)
258
-
259
- # Score percentage in circle
260
- ax.text(LEFT_MARGIN + 0.8, y_pos - 0.75, f"{overall_score['percentage']:.0f}%",
261
- fontsize=20, fontweight='bold', color=score_color,
262
  horizontalalignment='center', verticalalignment='center')
263
 
264
  # Score label
265
- ax.text(LEFT_MARGIN + 1.8, y_pos - 0.4, 'OVERALL ASSESSMENT',
266
- fontsize=10, color='#6b7280', fontweight='bold')
 
267
 
268
- ax.text(LEFT_MARGIN + 1.8, y_pos - 0.8, overall_score['label'].upper(),
269
- fontsize=24, fontweight='bold', color=score_color)
 
270
 
271
- # Score description
272
- score_desc = _get_score_description(overall_score['label'])
273
- ax.text(LEFT_MARGIN + 1.8, y_pos - 1.1, score_desc,
274
- fontsize=10, color='#6b7280', style='italic')
275
 
276
- y_pos -= 2.0
 
 
277
 
278
- # Candidate Information Section
279
- _add_section_header(ax, LEFT_MARGIN, y_pos, 'CANDIDATE PROFILE', CONTENT_WIDTH)
280
- y_pos -= 0.5
281
 
282
- # Professional info grid with better spacing
283
- info_items = [
284
- ('Full Name', report_data['candidate_name']),
285
- ('Email Address', report_data['candidate_email']),
286
- ('Applied Position', report_data['job_role']),
287
- ('Company', report_data['company']),
288
- ('Application Date', report_data['date_applied'])
289
- ]
290
-
291
- # Create info boxes
292
- for i, (label, value) in enumerate(info_items):
293
- y_offset = y_pos - (i * 0.35)
294
-
295
- # Label
296
- ax.text(LEFT_MARGIN + 0.1, y_offset, f"{label}:",
297
- fontsize=9, color='#6b7280', fontweight='bold')
298
-
299
- # Value with background
300
- value_rect = FancyBboxPatch(
301
- (LEFT_MARGIN + 2.2, y_offset - 0.1), CONTENT_WIDTH - 2.3, 0.25,
302
- boxstyle="round,pad=0.02",
303
- facecolor='#f8fafc',
304
- edgecolor='#e2e8f0',
305
- linewidth=0.5
306
- )
307
- ax.add_patch(value_rect)
308
-
309
- ax.text(LEFT_MARGIN + 2.3, y_offset, value,
310
- fontsize=10, color='#111827', fontweight='bold')
311
 
312
- y_pos -= 2.2
 
 
 
 
 
 
 
 
 
313
 
314
- # Skills Analysis Section
315
- _add_section_header(ax, LEFT_MARGIN, y_pos, 'SKILLS ANALYSIS', CONTENT_WIDTH)
316
  y_pos -= 0.5
317
 
318
  skills_data = report_data['skills_match']
319
 
320
- # Skills match visualization with better design
321
- bar_x = LEFT_MARGIN + 0.1
322
- bar_y = y_pos - 0.4
323
- bar_width = CONTENT_WIDTH - 0.2
324
- bar_height = 0.4
325
-
326
- # Background bar with rounded corners
327
- bg_bar = FancyBboxPatch(
328
- (bar_x, bar_y), bar_width, bar_height,
329
- boxstyle="round,pad=0.02",
330
- facecolor='#f1f5f9',
331
- edgecolor='#cbd5e1',
332
- linewidth=1
333
- )
334
- ax.add_patch(bg_bar)
335
 
336
- # Progress bar
337
- progress_width = max(0.3, bar_width * (skills_data['ratio'] / 100)) # Minimum visible width
338
- progress_color = _get_score_color(skills_data['score'])
339
- progress_bar = FancyBboxPatch(
340
- (bar_x, bar_y), progress_width, bar_height,
341
- boxstyle="round,pad=0.02",
342
- facecolor=progress_color,
343
- edgecolor='none'
344
- )
345
- ax.add_patch(progress_bar)
346
 
347
- # Percentage text with better positioning
348
- ax.text(bar_x + bar_width/2, bar_y + bar_height/2,
349
- f"{skills_data['ratio']:.0f}% SKILLS MATCH",
350
- fontsize=12, fontweight='bold', color='white',
 
 
 
 
 
 
351
  horizontalalignment='center', verticalalignment='center')
352
 
353
- y_pos -= 0.9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
- # Skills details with better formatting
356
- skills_items = [
357
- ('Required Skills', skills_data['required'], '#dc2626'),
358
- ('Candidate Skills', skills_data['candidate'], '#2563eb'),
359
- ('Matching Skills', skills_data['common'], '#059669')
360
- ]
361
 
362
- for i, (label, value, color) in enumerate(skills_items):
363
- y_offset = y_pos - (i * 0.6)
 
 
364
 
365
- # Color indicator
366
- indicator = Circle((LEFT_MARGIN + 0.15, y_offset), 0.08,
367
- facecolor=color, edgecolor='none')
368
- ax.add_patch(indicator)
369
 
370
- # Label
371
- ax.text(LEFT_MARGIN + 0.35, y_offset, f"{label}:",
372
- fontsize=10, color='#374151', fontweight='bold')
373
 
374
- # Wrap and display skills
375
- if value and value != 'N/A':
376
- wrapped_value = textwrap.fill(value, width=65)
377
- lines = wrapped_value.split('\n')
378
- for j, line in enumerate(lines[:2]): # Max 2 lines
379
- ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25 - (j * 0.2), line,
380
- fontsize=9, color='#6b7280')
381
- else:
382
- ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25, 'None specified',
383
- fontsize=9, color='#9ca3af', style='italic')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
  # Save first page
386
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
387
  plt.close(fig)
388
 
389
- # Page 2+: Interview Transcript
390
- if report_data['qa_log']:
391
- _create_transcript_pages(pdf, report_data['qa_log'], A4_WIDTH, A4_HEIGHT,
392
- LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN)
 
393
 
394
  buffer.seek(0)
395
  return buffer
@@ -418,7 +403,6 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
418
 
419
  for line in lines:
420
  line = line.strip()
421
-
422
  if line.startswith('Candidate Name:'):
423
  data['candidate_name'] = line.split(':', 1)[1].strip()
424
  elif line.startswith('Candidate Email:'):
@@ -515,54 +499,12 @@ def _get_score_color(score_label: str) -> str:
515
  return '#dc2626' # Red
516
 
517
 
518
- def _get_score_description(score_label: str) -> str:
519
- """Get description based on score label."""
520
- descriptions = {
521
- 'Excellent': 'Outstanding candidate with strong alignment',
522
- 'Good': 'Solid candidate with good potential',
523
- 'Satisfactory': 'Adequate candidate with room for growth',
524
- 'Needs Improvement': 'Candidate requires significant development'
525
- }
526
- return descriptions.get(score_label, 'Assessment completed')
527
-
528
-
529
- def _add_section_header(ax, x: float, y: float, title: str, width: float):
530
- """Add a professional section header with separator line."""
531
- # Background for header
532
- header_bg = FancyBboxPatch(
533
- (x - 0.05, y - 0.05), width + 0.1, 0.35,
534
- boxstyle="round,pad=0.02",
535
- facecolor='#f8fafc',
536
- edgecolor='#e2e8f0',
537
- linewidth=1
538
- )
539
- ax.add_patch(header_bg)
540
-
541
- # Title
542
- ax.text(x + 0.1, y + 0.1, title, fontsize=12, fontweight='bold', color='#1e293b')
543
-
544
- # Decorative line
545
- line = plt.Line2D([x + 0.1, x + width - 0.1], [y - 0.15, y - 0.15],
546
- color='#3b82f6', linewidth=2)
547
- ax.add_line(line)
548
-
549
-
550
- def _calculate_dynamic_box_height(text: str, width_chars: int = 75, base_height: float = 0.8) -> float:
551
- """Calculate dynamic height for text box based on content."""
552
- wrapped_lines = textwrap.wrap(text, width=width_chars)
553
- num_lines = len(wrapped_lines)
554
- # Base height + additional height per line
555
- return base_height + (max(0, num_lines - 2) * 0.15)
556
-
557
-
558
- def _create_transcript_pages(pdf, qa_log: List[Dict], page_width: float, page_height: float,
559
- left_margin: float, right_margin: float,
560
- top_margin: float, bottom_margin: float):
561
- """Create professional pages for interview transcript with dynamic text wrapping."""
562
  content_width = page_width - left_margin - right_margin
563
- wrapper = textwrap.TextWrapper(width=75)
564
 
565
- # Create pages dynamically based on content
566
  fig = plt.figure(figsize=(page_width, page_height))
567
  fig.patch.set_facecolor('white')
568
  ax = fig.add_subplot(111)
@@ -570,311 +512,80 @@ def _create_transcript_pages(pdf, qa_log: List[Dict], page_width: float, page_he
570
  ax.set_ylim(0, page_height)
571
  ax.axis('off')
572
 
573
- # Initialize page
574
  y_pos = page_height - top_margin
575
- page_num = 0
576
 
577
- # Header for first transcript page
578
- _add_transcript_header(ax, left_margin, y_pos, content_width, page_num + 2)
579
- y_pos -= 1.0
580
 
581
- for idx, qa in enumerate(qa_log):
582
- # Calculate space needed for this Q&A
583
- q_height = _calculate_dynamic_box_height(qa['question'])
584
- a_height = _calculate_dynamic_box_height(qa['answer'], width_chars=70)
585
- total_height = q_height + a_height + 2.5 # Include spacing
586
-
587
- # Check if we need a new page
588
- if y_pos - total_height < bottom_margin + 1.0:
589
- # Save current page
590
- pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
591
- plt.close(fig)
592
-
593
- # Create new page
594
- fig = plt.figure(figsize=(page_width, page_height))
595
- fig.patch.set_facecolor('white')
596
- ax = fig.add_subplot(111)
597
- ax.set_xlim(0, page_width)
598
- ax.set_ylim(0, page_height)
599
- ax.axis('off')
600
-
601
- y_pos = page_height - top_margin
602
- page_num += 1
603
- _add_transcript_header(ax, left_margin, y_pos, content_width, page_num + 2)
604
- y_pos -= 1.0
605
-
606
- # Question section with dynamic height
607
- q_box = FancyBboxPatch(
608
- (left_margin, y_pos - q_height), content_width, q_height,
609
- boxstyle="round,pad=0.05",
610
- facecolor='#eff6ff',
611
- edgecolor='#3b82f6',
612
- linewidth=2
613
- )
614
- ax.add_patch(q_box)
615
-
616
- # Question number badge
617
- q_badge = Circle((left_margin + 0.4, y_pos - q_height/2), 0.2,
618
- facecolor='#3b82f6', edgecolor='white', linewidth=2)
619
- ax.add_patch(q_badge)
620
-
621
- ax.text(left_margin + 0.4, y_pos - q_height/2, f'{idx+1}',
622
- fontsize=12, fontweight='bold', color='white',
623
- horizontalalignment='center', verticalalignment='center')
624
-
625
- # Question text with proper wrapping
626
- ax.text(left_margin + 0.8, y_pos - 0.2, 'QUESTION',
627
- fontsize=9, fontweight='bold', color='#1e40af')
628
-
629
- q_wrapped = wrapper.wrap(qa['question'])
630
- for j, line in enumerate(q_wrapped):
631
- ax.text(left_margin + 0.8, y_pos - 0.4 - (j * 0.15), line,
632
- fontsize=11, fontweight='bold', color='#1e293b')
633
 
634
- y_pos -= (q_height + 0.3)
635
 
636
- # Answer section with dynamic height
637
  answer_text = qa['answer']
638
- # Handle salary question replacement
639
  if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
640
  answer_text = "Prefer not to disclose"
641
-
642
- answer_box = FancyBboxPatch(
643
- (left_margin + 0.2, y_pos - a_height), content_width - 0.4, a_height,
644
- boxstyle="round,pad=0.05",
645
- facecolor='#f9fafb',
646
- edgecolor='#d1d5db',
647
- linewidth=1
648
- )
649
- ax.add_patch(answer_box)
650
-
651
- ax.text(left_margin + 0.4, y_pos - 0.2, 'CANDIDATE RESPONSE',
652
- fontsize=9, fontweight='bold', color='#6b7280')
653
-
654
- a_wrapped = wrapper.wrap(answer_text)
655
- for j, line in enumerate(a_wrapped):
656
- if j * 0.15 < a_height - 0.4: # Ensure text fits in box
657
- ax.text(left_margin + 0.4, y_pos - 0.4 - (j * 0.15), line,
658
- fontsize=10, color='#374151')
659
-
660
- y_pos -= (a_height + 0.4)
661
-
662
- # Evaluation section
663
- eval_box = FancyBboxPatch(
664
- (left_margin + 0.4, y_pos - 0.8), content_width - 0.8, 0.8,
665
- boxstyle="round,pad=0.05",
666
- facecolor='#fefefe',
667
- edgecolor='#e5e7eb',
668
- linewidth=1
669
- )
670
- ax.add_patch(eval_box)
671
-
672
- # Score badge
673
- score_color = _get_score_color(qa['score'])
674
- score_badge = FancyBboxPatch(
675
- (left_margin + 0.6, y_pos - 0.35), 1.2, 0.25,
676
- boxstyle="round,pad=0.02",
677
- facecolor=score_color,
678
- alpha=0.2,
679
- edgecolor=score_color,
680
- linewidth=1
681
- )
682
- ax.add_patch(score_badge)
683
 
684
- ax.text(left_margin + 1.2, y_pos - 0.225, qa['score'],
685
- fontsize=10, fontweight='bold', color=score_color,
686
- horizontalalignment='center', verticalalignment='center')
687
 
688
- # Feedback with proper wrapping
689
- if qa['feedback'] and qa['feedback'] != 'N/A':
690
- ax.text(left_margin + 2.2, y_pos - 0.15, 'Feedback:',
691
- fontsize=9, fontweight='bold', color='#6b7280')
692
-
693
- # Calculate feedback area width
694
- feedback_width = content_width - 2.6
695
- feedback_wrapper = textwrap.TextWrapper(width=int(feedback_width * 10))
696
- f_wrapped = feedback_wrapper.wrap(qa['feedback'])
 
 
 
 
 
 
 
 
697
 
698
- for j, line in enumerate(f_wrapped[:2]): # Max 2 lines
699
- ax.text(left_margin + 2.2, y_pos - 0.35 - (j * 0.15), line,
700
- fontsize=9, color='#6b7280', style='italic')
701
-
702
- y_pos -= 1.2
703
-
704
- # Add separator between questions (except last)
705
- if idx < len(qa_log) - 1:
706
- separator = plt.Line2D([left_margin + 1, left_margin + content_width - 1],
707
- [y_pos + 0.3, y_pos + 0.3],
708
- color='#e5e7eb', linewidth=1, linestyle='--')
709
- ax.add_line(separator)
710
  y_pos -= 0.3
711
 
712
- # Save last page
713
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
714
  plt.close(fig)
715
 
716
 
717
- def _add_transcript_header(ax, left_margin: float, y_pos: float, content_width: float, page_num: int):
718
- """Add header for transcript pages."""
719
- # Header background
720
- header_rect = FancyBboxPatch(
721
- (left_margin, y_pos - 0.6), content_width, 0.6,
722
- boxstyle="round,pad=0.02",
723
- facecolor='#1e40af',
724
- edgecolor='none'
725
- )
726
- ax.add_patch(header_rect)
727
-
728
- ax.text(left_margin + 0.2, y_pos - 0.3, 'INTERVIEW TRANSCRIPT',
729
- fontsize=14, fontweight='bold', color='white')
730
-
731
- # Page number
732
- ax.text(left_margin + content_width - 0.2, y_pos - 0.3, f'Page {page_num}',
733
- fontsize=10, color='white', horizontalalignment='right')
734
-
735
-
736
- # Keep the original advanced version as fallback
737
- def create_pdf_report_advanced(report_text: str) -> BytesIO:
738
- """
739
- Alternative implementation using reportlab for professional PDF generation.
740
- This creates cleaner, more consistent PDFs with better text handling.
741
- Install with: pip install reportlab
742
- """
743
- try:
744
- from reportlab.lib.pagesizes import A4
745
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
746
- from reportlab.lib.units import inch
747
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
748
- from reportlab.lib.colors import HexColor
749
- from reportlab.lib.enums import TA_LEFT, TA_CENTER
750
-
751
- buffer = BytesIO()
752
-
753
- # Create document with consistent margins
754
- doc = SimpleDocTemplate(
755
- buffer,
756
- pagesize=A4,
757
- rightMargin=0.75*inch,
758
- leftMargin=0.75*inch,
759
- topMargin=1*inch,
760
- bottomMargin=1*inch
761
- )
762
-
763
- # Define consistent styles
764
- styles = getSampleStyleSheet()
765
-
766
- # Title style
767
- title_style = ParagraphStyle(
768
- 'CustomTitle',
769
- parent=styles['Heading1'],
770
- fontSize=16,
771
- textColor=HexColor('#111827'),
772
- spaceAfter=12,
773
- alignment=TA_CENTER
774
- )
775
-
776
- # Header style for metadata
777
- header_style = ParagraphStyle(
778
- 'Header',
779
- parent=styles['Normal'],
780
- fontSize=10,
781
- textColor=HexColor('#111827'),
782
- spaceAfter=4
783
- )
784
-
785
- # Question style
786
- question_style = ParagraphStyle(
787
- 'Question',
788
- parent=styles['Heading2'],
789
- fontSize=11,
790
- textColor=HexColor('#1e3a8a'),
791
- spaceBefore=12,
792
- spaceAfter=6,
793
- fontName='Helvetica-Bold'
794
- )
795
-
796
- # Answer style
797
- answer_style = ParagraphStyle(
798
- 'Answer',
799
- parent=styles['Normal'],
800
- fontSize=10,
801
- textColor=HexColor('#374151'),
802
- leftIndent=20,
803
- spaceAfter=4
804
- )
805
-
806
- # Score style
807
- score_style = ParagraphStyle(
808
- 'Score',
809
- parent=styles['Normal'],
810
- fontSize=10,
811
- textColor=HexColor('#059669'),
812
- leftIndent=20,
813
- fontName='Helvetica-Bold',
814
- spaceAfter=4
815
- )
816
-
817
- # Feedback style
818
- feedback_style = ParagraphStyle(
819
- 'Feedback',
820
- parent=styles['Normal'],
821
- fontSize=10,
822
- textColor=HexColor('#dc2626'),
823
- leftIndent=20,
824
- spaceAfter=8
825
- )
826
-
827
- # Build document content
828
- story = []
829
- lines = report_text.split('\n')
830
-
831
- for line in lines:
832
- stripped = line.strip()
833
- if not stripped:
834
- story.append(Spacer(1, 6))
835
- elif 'Interview Report' in stripped:
836
- story.append(Paragraph(stripped, title_style))
837
- story.append(Spacer(1, 12))
838
- elif any(stripped.startswith(x) for x in ['Candidate Name:', 'Candidate Email:', 'Job Applied:', 'Company:', 'Date Applied:']):
839
- story.append(Paragraph(stripped, header_style))
840
- elif stripped.startswith('Skills Match Summary:') or stripped.startswith('Interview Transcript'):
841
- story.append(Spacer(1, 12))
842
- story.append(Paragraph(f"<b>{stripped}</b>", header_style))
843
- story.append(Spacer(1, 6))
844
- elif stripped.startswith('Question'):
845
- story.append(Paragraph(stripped, question_style))
846
- elif stripped.startswith('Answer:'):
847
- # Handle salary replacement
848
- if "0$" in stripped or " 0 " in stripped or "$0" in stripped:
849
- stripped = stripped.replace("0$", "Prefer not to disclose").replace(" 0 ", " Prefer not to disclose ").replace("$0", "Prefer not to disclose")
850
- story.append(Paragraph(stripped, answer_style))
851
- elif stripped.startswith('Score:'):
852
- story.append(Paragraph(stripped, score_style))
853
- elif stripped.startswith('Feedback:'):
854
- story.append(Paragraph(stripped, feedback_style))
855
- else:
856
- # Regular text with proper indentation for sub-items
857
- if stripped.startswith(' '):
858
- indent_style = ParagraphStyle(
859
- 'Indented',
860
- parent=styles['Normal'],
861
- fontSize=10,
862
- leftIndent=20,
863
- spaceAfter=2
864
- )
865
- story.append(Paragraph(stripped, indent_style))
866
- else:
867
- story.append(Paragraph(stripped, styles['Normal']))
868
-
869
- # Build PDF
870
- doc.build(story)
871
- buffer.seek(0)
872
- return buffer
873
-
874
- except ImportError:
875
- # Fallback to matplotlib version if reportlab not available
876
- print("Reportlab not installed. Using matplotlib version.")
877
- return create_pdf_report(report_text)
878
-
879
-
880
- __all__ = ['generate_llm_interview_report', 'create_pdf_report']
 
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
 
32
  from datetime import datetime
33
 
34
 
 
67
 
68
  candidate_skills: List[str] = candidate_features.get('skills', []) or []
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
  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}')
 
116
  formatted_job_skills = ', '.join(job_skills) if job_skills else 'N/A'
117
  formatted_candidate_skills = ', '.join(candidate_skills) if candidate_skills else 'N/A'
118
  formatted_common = ', '.join(sorted(common)) if common else 'None'
119
+
120
  lines.append(f' Required Skills: {formatted_job_skills}')
121
  lines.append(f' Candidate Skills: {formatted_candidate_skills}')
122
  lines.append(f' Skills in Common: {formatted_common}')
 
169
  # Margins in inches
170
  LEFT_MARGIN = 0.75
171
  RIGHT_MARGIN = 0.75
172
+ TOP_MARGIN = 0.75
173
+ BOTTOM_MARGIN = 0.75
174
 
175
  # Calculate content area
176
  CONTENT_WIDTH = A4_WIDTH - LEFT_MARGIN - RIGHT_MARGIN
177
  CONTENT_HEIGHT = A4_HEIGHT - TOP_MARGIN - BOTTOM_MARGIN
178
 
179
+ # Professional color scheme - single accent color
180
+ ACCENT_COLOR = '#1e40af' # Dark blue
181
+ TEXT_COLOR = '#111827' # Dark gray/black
182
+ LIGHT_GRAY = '#f8fafc' # Light background
183
+ BORDER_COLOR = '#e2e8f0' # Light border
184
+
185
  # Parse report data
186
  report_data = _parse_report_text(report_text)
187
 
188
  # Create PDF
189
  with PdfPages(buffer) as pdf:
190
+ # Page 1: Header, Candidate Info, and Skills Summary
191
  fig = plt.figure(figsize=(A4_WIDTH, A4_HEIGHT))
192
  fig.patch.set_facecolor('white')
193
 
 
200
  # Current Y position (start from top)
201
  y_pos = A4_HEIGHT - TOP_MARGIN
202
 
203
+ # === HEADER SECTION ===
204
+ # Clean header with company info
205
+ ax.text(LEFT_MARGIN, y_pos, 'INTERVIEW ASSESSMENT REPORT',
206
+ fontsize=20, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
  # Date
209
  current_date = datetime.now().strftime('%B %d, %Y')
210
+ ax.text(A4_WIDTH - RIGHT_MARGIN, y_pos, current_date,
211
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif',
212
  horizontalalignment='right')
213
 
214
+ y_pos -= 0.8
215
 
216
+ # === CANDIDATE INFO AND OVERALL SCORE ===
217
+ # Large overall score box (prominent)
218
  overall_score = _calculate_overall_score(report_data)
219
  score_color = _get_score_color(overall_score['label'])
220
 
221
+ # Score box on the right
222
+ score_box_width = 2.5
223
+ score_box_height = 1.8
224
+ score_x = A4_WIDTH - RIGHT_MARGIN - score_box_width
225
+
226
+ # Score background
227
+ score_rect = FancyBboxPatch(
228
+ (score_x, y_pos - score_box_height), score_box_width, score_box_height,
229
+ boxstyle="round,pad=0.1",
230
+ facecolor=LIGHT_GRAY,
231
+ edgecolor=ACCENT_COLOR,
232
+ linewidth=2
233
  )
234
+ ax.add_patch(score_rect)
235
 
236
+ # Large score percentage
237
+ ax.text(score_x + score_box_width/2, y_pos - 0.6, f"{overall_score['percentage']:.0f}%",
238
+ fontsize=32, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif',
 
 
 
 
 
 
239
  horizontalalignment='center', verticalalignment='center')
240
 
241
  # Score label
242
+ ax.text(score_x + score_box_width/2, y_pos - 1.2, 'OVERALL SCORE',
243
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif',
244
+ horizontalalignment='center', fontweight='bold')
245
 
246
+ ax.text(score_x + score_box_width/2, y_pos - 1.5, overall_score['label'].upper(),
247
+ fontsize=14, fontweight='bold', color=score_color, fontfamily='sans-serif',
248
+ horizontalalignment='center')
249
 
250
+ # Candidate information on the left
251
+ info_width = CONTENT_WIDTH - score_box_width - 0.5
 
 
252
 
253
+ # Candidate name (large)
254
+ ax.text(LEFT_MARGIN, y_pos - 0.3, report_data['candidate_name'],
255
+ fontsize=18, fontweight='bold', color=TEXT_COLOR, fontfamily='sans-serif')
256
 
257
+ # Position and company
258
+ ax.text(LEFT_MARGIN, y_pos - 0.7, f"{report_data['job_role']} • {report_data['company']}",
259
+ fontsize=12, color=TEXT_COLOR, fontfamily='sans-serif')
260
 
261
+ # Email and date
262
+ ax.text(LEFT_MARGIN, y_pos - 1.0, f"Email: {report_data['candidate_email']}",
263
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
264
+
265
+ ax.text(LEFT_MARGIN, y_pos - 1.3, f"Application Date: {report_data['date_applied']}",
266
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
+ y_pos -= 2.5
269
+
270
+ # === SKILLS MATCH SUMMARY ===
271
+ # Section header
272
+ ax.text(LEFT_MARGIN, y_pos, 'SKILLS MATCH SUMMARY',
273
+ fontsize=14, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
274
+
275
+ # Underline
276
+ ax.plot([LEFT_MARGIN, LEFT_MARGIN + 3], [y_pos - 0.1, y_pos - 0.1],
277
+ color=ACCENT_COLOR, linewidth=2)
278
 
 
 
279
  y_pos -= 0.5
280
 
281
  skills_data = report_data['skills_match']
282
 
283
+ # Skills match percentage bar
284
+ bar_width = CONTENT_WIDTH - 1
285
+ bar_height = 0.3
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
+ # Background bar
288
+ bg_rect = Rectangle((LEFT_MARGIN + 0.5, y_pos - bar_height), bar_width, bar_height,
289
+ facecolor=LIGHT_GRAY, edgecolor=BORDER_COLOR)
290
+ ax.add_patch(bg_rect)
 
 
 
 
 
 
291
 
292
+ # Progress bar
293
+ progress_width = bar_width * (skills_data['ratio'] / 100)
294
+ progress_rect = Rectangle((LEFT_MARGIN + 0.5, y_pos - bar_height), progress_width, bar_height,
295
+ facecolor=ACCENT_COLOR, edgecolor='none')
296
+ ax.add_patch(progress_rect)
297
+
298
+ # Percentage text
299
+ ax.text(LEFT_MARGIN + 0.5 + bar_width/2, y_pos - bar_height/2,
300
+ f"{skills_data['ratio']:.0f}% Skills Match",
301
+ fontsize=11, fontweight='bold', color='white', fontfamily='sans-serif',
302
  horizontalalignment='center', verticalalignment='center')
303
 
304
+ y_pos -= 0.8
305
+
306
+ # Skills details (simplified)
307
+ ax.text(LEFT_MARGIN, y_pos, f"Required Skills: {skills_data['required'][:80]}{'...' if len(skills_data['required']) > 80 else ''}",
308
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
309
+
310
+ y_pos -= 0.3
311
+
312
+ ax.text(LEFT_MARGIN, y_pos, f"Candidate Skills: {skills_data['candidate'][:80]}{'...' if len(skills_data['candidate']) > 80 else ''}",
313
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
314
+
315
+ y_pos -= 0.3
316
+
317
+ ax.text(LEFT_MARGIN, y_pos, f"Matching Skills: {skills_data['common'][:80]}{'...' if len(skills_data['common']) > 80 else ''}",
318
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
319
 
320
+ y_pos -= 0.8
 
 
 
 
 
321
 
322
+ # === INTERVIEW TRANSCRIPT PREVIEW ===
323
+ if report_data['qa_log']:
324
+ ax.text(LEFT_MARGIN, y_pos, 'INTERVIEW TRANSCRIPT',
325
+ fontsize=14, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
326
 
327
+ # Underline
328
+ ax.plot([LEFT_MARGIN, LEFT_MARGIN + 3], [y_pos - 0.1, y_pos - 0.1],
329
+ color=ACCENT_COLOR, linewidth=2)
 
330
 
331
+ y_pos -= 0.5
 
 
332
 
333
+ # Show first 2-3 Q&As on first page
334
+ max_qa_on_page1 = min(3, len(report_data['qa_log']))
335
+
336
+ for i in range(max_qa_on_page1):
337
+ qa = report_data['qa_log'][i]
338
+
339
+ # Check if we have space
340
+ if y_pos < BOTTOM_MARGIN + 2:
341
+ break
342
+
343
+ # Question
344
+ ax.text(LEFT_MARGIN, y_pos, f"Q{i+1}: {qa['question'][:90]}{'...' if len(qa['question']) > 90 else ''}",
345
+ fontsize=11, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
346
+
347
+ y_pos -= 0.4
348
+
349
+ # Answer
350
+ answer_text = qa['answer']
351
+ if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
352
+ answer_text = "Prefer not to disclose"
353
+
354
+ wrapped_answer = textwrap.fill(answer_text, width=85)
355
+ answer_lines = wrapped_answer.split('\n')[:2] # Max 2 lines
356
+
357
+ for line in answer_lines:
358
+ ax.text(LEFT_MARGIN + 0.3, y_pos, line,
359
+ fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
360
+ y_pos -= 0.25
361
+
362
+ # Evaluation
363
+ eval_color = _get_score_color(qa['score'])
364
+ ax.text(LEFT_MARGIN + 0.3, y_pos, f"Evaluation: {qa['score']}",
365
+ fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
366
+
367
+ y_pos -= 0.6
368
 
369
  # Save first page
370
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
371
  plt.close(fig)
372
 
373
+ # === PAGE 2: REMAINING TRANSCRIPT ===
374
+ if report_data['qa_log'] and len(report_data['qa_log']) > 3:
375
+ _create_transcript_page(pdf, report_data['qa_log'][3:], A4_WIDTH, A4_HEIGHT,
376
+ LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN,
377
+ ACCENT_COLOR, TEXT_COLOR, start_index=4)
378
 
379
  buffer.seek(0)
380
  return buffer
 
403
 
404
  for line in lines:
405
  line = line.strip()
 
406
  if line.startswith('Candidate Name:'):
407
  data['candidate_name'] = line.split(':', 1)[1].strip()
408
  elif line.startswith('Candidate Email:'):
 
499
  return '#dc2626' # Red
500
 
501
 
502
+ def _create_transcript_page(pdf, qa_log: List[Dict], page_width: float, page_height: float,
503
+ left_margin: float, right_margin: float, top_margin: float, bottom_margin: float,
504
+ accent_color: str, text_color: str, start_index: int = 1):
505
+ """Create a clean page for remaining interview transcript."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  content_width = page_width - left_margin - right_margin
 
507
 
 
508
  fig = plt.figure(figsize=(page_width, page_height))
509
  fig.patch.set_facecolor('white')
510
  ax = fig.add_subplot(111)
 
512
  ax.set_ylim(0, page_height)
513
  ax.axis('off')
514
 
515
+ # Start from top
516
  y_pos = page_height - top_margin
 
517
 
518
+ # Page header
519
+ ax.text(left_margin, y_pos, 'INTERVIEW TRANSCRIPT (CONTINUED)',
520
+ fontsize=14, fontweight='bold', color=accent_color, fontfamily='sans-serif')
521
 
522
+ # Underline
523
+ ax.plot([left_margin, left_margin + 4], [y_pos - 0.1, y_pos - 0.1],
524
+ color=accent_color, linewidth=2)
525
+
526
+ y_pos -= 0.8
527
+
528
+ # Process remaining Q&As
529
+ for i, qa in enumerate(qa_log):
530
+ # Check if we have space for this Q&A
531
+ if y_pos < bottom_margin + 1.5:
532
+ break
533
+
534
+ # Question
535
+ question_text = f"Q{start_index + i}: {qa['question']}"
536
+ wrapped_question = textwrap.fill(question_text, width=85)
537
+ question_lines = wrapped_question.split('\n')
538
+
539
+ for line in question_lines:
540
+ ax.text(left_margin, y_pos, line,
541
+ fontsize=11, fontweight='bold', color=accent_color, fontfamily='sans-serif')
542
+ y_pos -= 0.3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
+ y_pos -= 0.1
545
 
546
+ # Answer
547
  answer_text = qa['answer']
 
548
  if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
549
  answer_text = "Prefer not to disclose"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
 
551
+ wrapped_answer = textwrap.fill(answer_text, width=80)
552
+ answer_lines = wrapped_answer.split('\n')
 
553
 
554
+ for line in answer_lines[:3]: # Max 3 lines per answer
555
+ ax.text(left_margin + 0.3, y_pos, line,
556
+ fontsize=10, color=text_color, fontfamily='sans-serif')
557
+ y_pos -= 0.25
558
+
559
+ # Evaluation
560
+ eval_color = _get_score_color(qa['score'])
561
+ ax.text(left_margin + 0.3, y_pos, f"Evaluation: {qa['score']}",
562
+ fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
563
+
564
+ y_pos -= 0.2
565
+
566
+ # Feedback (if available and space permits)
567
+ if qa['feedback'] and qa['feedback'] != 'N/A' and y_pos > bottom_margin + 0.8:
568
+ feedback_text = f"Feedback: {qa['feedback']}"
569
+ wrapped_feedback = textwrap.fill(feedback_text, width=75)
570
+ feedback_lines = wrapped_feedback.split('\n')[:2] # Max 2 lines
571
 
572
+ for line in feedback_lines:
573
+ ax.text(left_margin + 0.3, y_pos, line,
574
+ fontsize=9, color='#6b7280', fontfamily='sans-serif', style='italic')
575
+ y_pos -= 0.2
576
+
577
+ y_pos -= 0.4
578
+
579
+ # Add separator line between questions
580
+ if i < len(qa_log) - 1 and y_pos > bottom_margin + 1:
581
+ ax.plot([left_margin + 0.5, left_margin + content_width - 0.5],
582
+ [y_pos + 0.1, y_pos + 0.1],
583
+ color='#e5e7eb', linewidth=0.5, linestyle='--')
584
  y_pos -= 0.3
585
 
586
+ # Save page
587
  pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
588
  plt.close(fig)
589
 
590
 
591
+ __all__ = ['generate_llm_interview_report', 'create_pdf_report']