Spaces:
Paused
Paused
Commit
·
194e7a7
1
Parent(s):
aba3be2
updated
Browse files- backend/services/report_generator.py +175 -128
backend/services/report_generator.py
CHANGED
@@ -139,6 +139,11 @@ def generate_llm_interview_report(application) -> str:
|
|
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 |
|
@@ -542,151 +547,190 @@ def _add_section_header(ax, x: float, y: float, title: str, width: float):
|
|
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 |
-
#
|
553 |
-
|
554 |
-
|
|
|
|
|
|
|
|
|
555 |
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
|
563 |
-
|
564 |
-
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
|
569 |
-
|
570 |
-
|
571 |
-
|
572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
573 |
)
|
574 |
-
ax.add_patch(
|
575 |
|
576 |
-
|
577 |
-
|
|
|
|
|
578 |
|
579 |
-
|
580 |
-
|
581 |
-
|
582 |
|
583 |
-
|
|
|
|
|
584 |
|
585 |
-
|
586 |
-
|
587 |
-
|
|
|
588 |
|
589 |
-
|
590 |
-
|
591 |
-
|
592 |
-
|
593 |
-
|
594 |
-
|
595 |
-
|
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 |
-
|
636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
637 |
ax.text(left_margin + 0.4, y_pos - 0.4 - (j * 0.15), line,
|
638 |
fontsize=10, color='#374151')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
639 |
|
640 |
-
|
641 |
-
|
642 |
-
|
643 |
-
|
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,
|
659 |
-
edgecolor=score_color,
|
660 |
-
linewidth=1
|
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 |
-
|
681 |
-
|
682 |
-
|
683 |
-
|
684 |
-
|
685 |
-
|
686 |
-
|
687 |
-
|
688 |
-
|
689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
690 |
|
691 |
|
692 |
# Keep the original advanced version as fallback
|
@@ -800,6 +844,9 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
|
|
800 |
elif stripped.startswith('Question'):
|
801 |
story.append(Paragraph(stripped, question_style))
|
802 |
elif stripped.startswith('Answer:'):
|
|
|
|
|
|
|
803 |
story.append(Paragraph(stripped, answer_style))
|
804 |
elif stripped.startswith('Score:'):
|
805 |
story.append(Paragraph(stripped, score_style))
|
@@ -830,4 +877,4 @@ def create_pdf_report_advanced(report_text: str) -> BytesIO:
|
|
830 |
return create_pdf_report(report_text)
|
831 |
|
832 |
|
833 |
-
__all__ = ['generate_llm_interview_report', 'create_pdf_report']
|
|
|
139 |
for idx, entry in enumerate(qa_log, 1):
|
140 |
q = entry.get("question", "N/A")
|
141 |
a = entry.get("answer", "N/A")
|
142 |
+
|
143 |
+
# Handle salary question specifically
|
144 |
+
if "salary" in q.lower() and (a == "0$" or a == "0" or a == "$0"):
|
145 |
+
a = "Prefer not to disclose"
|
146 |
+
|
147 |
eval_score = entry.get("evaluation", {}).get("score", "N/A")
|
148 |
eval_feedback = entry.get("evaluation", {}).get("feedback", "N/A")
|
149 |
|
|
|
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)
|
569 |
+
ax.set_xlim(0, page_width)
|
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
|
|
|
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))
|
|
|
877 |
return create_pdf_report(report_text)
|
878 |
|
879 |
|
880 |
+
__all__ = ['generate_llm_interview_report', 'create_pdf_report']
|