Spaces:
Paused
Paused
"""Utilities for assembling and exporting interview reports. | |
This module provides two primary helpers used by the recruiter dashboard: | |
``generate_llm_interview_report(application)`` | |
Given a candidate's ``Application`` record, assemble a plain‑text report | |
summarising the interview. Because the interview process currently | |
executes entirely client‑side and does not persist questions or answers | |
to the database, this report focuses on the information available on | |
the server: the candidate's profile, the job requirements and a skills | |
match score. Should future iterations store richer interview data | |
server‑side, this function can be extended to include question/answer | |
transcripts, per‑question scores and LLM‑generated feedback. | |
``create_pdf_report(report_text)`` | |
Convert a multi‑line string into a simple PDF. The implementation | |
leverages Matplotlib's PDF backend (available by default) to avoid | |
heavyweight dependencies such as ReportLab or WeasyPrint, which are | |
absent from the runtime environment. Text is wrapped and split | |
across multiple pages as necessary. | |
""" | |
from __future__ import annotations | |
import json | |
from io import BytesIO | |
import textwrap | |
from typing import List, Dict, Any, Tuple | |
import matplotlib.pyplot as plt | |
from matplotlib.backends.backend_pdf import PdfPages | |
import matplotlib.patches as mpatches | |
from matplotlib.patches import Rectangle, FancyBboxPatch, Circle | |
import matplotlib.dates as mdates | |
from datetime import datetime | |
def generate_llm_interview_report(application) -> str: | |
"""Generate a human‑readable interview report for a candidate. | |
The report includes the candidate's name and email, job details, | |
application date, a computed skills match summary and placeholder | |
sections for future enhancements. If server‑side storage of | |
question/answer pairs is added later, this function can be updated | |
to incorporate those details. | |
Parameters | |
---------- | |
application : backend.models.database.Application | |
The SQLAlchemy Application instance representing the candidate's | |
job application. Assumed to have related ``job`` and | |
``date_applied`` attributes available. | |
Returns | |
------- | |
str | |
A multi‑line string containing the report contents. | |
""" | |
# Defensive imports to avoid circular dependencies at import time | |
try: | |
from datetime import datetime # noqa: F401 | |
except Exception: | |
pass | |
# Extract candidate skills and job skills | |
try: | |
candidate_features = json.loads(application.extracted_features) if application.extracted_features else {} | |
except Exception: | |
candidate_features = {} | |
candidate_skills: List[str] = candidate_features.get('skills', []) or [] | |
job_skills: List[str] = [] | |
try: | |
job_skills = json.loads(application.job.skills) if application.job and application.job.skills else [] | |
except Exception: | |
job_skills = [] | |
# Compute skills match ratio and label. Normalise to lower case for | |
# comparison and avoid dividing by zero when ``job_skills`` is empty. | |
candidate_set = {s.strip().lower() for s in candidate_skills} | |
job_set = {s.strip().lower() for s in job_skills} | |
common = candidate_set & job_set | |
ratio = len(common) / len(job_set) if job_set else 0.0 | |
if ratio >= 0.75: | |
score_label = 'Excellent' | |
elif ratio >= 0.5: | |
score_label = 'Good' | |
elif ratio >= 0.25: | |
score_label = 'Medium' | |
else: | |
score_label = 'Poor' | |
# Assemble report lines | |
lines: List[str] = [] | |
lines.append('Interview Report') | |
lines.append('=================') | |
lines.append('') | |
lines.append(f'Candidate Name: {application.name}') | |
lines.append(f'Candidate Email: {application.email}') | |
if application.job: | |
lines.append(f'Job Applied: {application.job.role}') | |
lines.append(f'Company: {application.job.company}') | |
else: | |
lines.append('Job Applied: N/A') | |
lines.append('Company: N/A') | |
# Format date_applied if available | |
try: | |
date_str = application.date_applied.strftime('%Y-%m-%d') if application.date_applied else 'N/A' | |
except Exception: | |
date_str = 'N/A' | |
lines.append(f'Date Applied: {date_str}') | |
lines.append('') | |
lines.append('Skills Match Summary:') | |
# Represent required and candidate skills as comma‑separated lists. Use | |
# title‑case for presentation and handle empty lists gracefully. | |
formatted_job_skills = ', '.join(job_skills) if job_skills else 'N/A' | |
formatted_candidate_skills = ', '.join(candidate_skills) if candidate_skills else 'N/A' | |
formatted_common = ', '.join(sorted(common)) if common else 'None' | |
lines.append(f' Required Skills: {formatted_job_skills}') | |
lines.append(f' Candidate Skills: {formatted_candidate_skills}') | |
lines.append(f' Skills in Common: {formatted_common}') | |
lines.append(f' Match Ratio: {ratio * 100:.0f}%') | |
lines.append(f' Score: {score_label}') | |
lines.append('') | |
lines.append('Interview Transcript & Evaluation:') | |
try: | |
if application.interview_log: | |
try: | |
qa_log = json.loads(application.interview_log) | |
except Exception: | |
qa_log = [] | |
if qa_log: | |
for idx, entry in enumerate(qa_log, 1): | |
q = entry.get("question", "N/A") | |
a = entry.get("answer", "N/A") | |
eval_score = entry.get("evaluation", {}).get("score", "N/A") | |
eval_feedback = entry.get("evaluation", {}).get("feedback", "N/A") | |
lines.append(f"\nQuestion {idx}: {q}") | |
lines.append(f"Answer: {a}") | |
lines.append(f"Score: {eval_score}") | |
lines.append(f"Feedback: {eval_feedback}") | |
else: | |
lines.append("No interview log data recorded.") | |
else: | |
lines.append("No interview log data recorded.") | |
except Exception as e: | |
lines.append(f"Error loading interview log: {e}") | |
return '\n'.join(lines) | |
def create_pdf_report(report_text: str) -> BytesIO: | |
"""Convert a formatted report into a clean, professional A4 PDF.""" | |
buffer = BytesIO() | |
# A4 dimensions in inches (210mm x 297mm) | |
A4_WIDTH = 8.27 | |
A4_HEIGHT = 11.69 | |
# Margins in inches | |
LEFT_MARGIN = 0.75 | |
RIGHT_MARGIN = 0.75 | |
TOP_MARGIN = 1.0 | |
BOTTOM_MARGIN = 1.0 | |
# Calculate content area | |
CONTENT_WIDTH = A4_WIDTH - LEFT_MARGIN - RIGHT_MARGIN | |
CONTENT_HEIGHT = A4_HEIGHT - TOP_MARGIN - BOTTOM_MARGIN | |
# Parse report data | |
report_data = _parse_report_text(report_text) | |
# Create PDF | |
with PdfPages(buffer) as pdf: | |
# Page 1: Header and Summary | |
fig = plt.figure(figsize=(A4_WIDTH, A4_HEIGHT)) | |
fig.patch.set_facecolor('white') | |
# Create main axis | |
ax = fig.add_subplot(111) | |
ax.set_xlim(0, A4_WIDTH) | |
ax.set_ylim(0, A4_HEIGHT) | |
ax.axis('off') | |
# Current Y position (start from top) | |
y_pos = A4_HEIGHT - TOP_MARGIN | |
# Professional Header with gradient-like effect | |
header_rect = FancyBboxPatch( | |
(LEFT_MARGIN, y_pos - 1.8), CONTENT_WIDTH, 1.8, | |
boxstyle="round,pad=0.05", | |
facecolor='#1e40af', | |
edgecolor='none' | |
) | |
ax.add_patch(header_rect) | |
# Company Logo placeholder (professional circle) | |
logo_circle = Circle((LEFT_MARGIN + 0.6, y_pos - 0.9), 0.4, | |
color='white', alpha=0.15, linewidth=2, | |
edgecolor='white') | |
ax.add_patch(logo_circle) | |
# Logo text | |
ax.text(LEFT_MARGIN + 0.6, y_pos - 0.9, 'IR', | |
fontsize=16, fontweight='bold', color='white', | |
horizontalalignment='center', verticalalignment='center') | |
# Header text | |
ax.text(LEFT_MARGIN + 1.4, y_pos - 0.5, 'INTERVIEW ASSESSMENT REPORT', | |
fontsize=18, fontweight='bold', color='white', | |
verticalalignment='center') | |
ax.text(LEFT_MARGIN + 1.4, y_pos - 0.9, | |
f"{report_data['job_role']} • {report_data['company']}", | |
fontsize=11, color='white', alpha=0.9, | |
verticalalignment='center') | |
# Date | |
current_date = datetime.now().strftime('%B %d, %Y') | |
ax.text(A4_WIDTH - RIGHT_MARGIN, y_pos - 1.4, f'Generated: {current_date}', | |
fontsize=9, color='white', alpha=0.8, | |
horizontalalignment='right') | |
y_pos -= 2.2 | |
# Overall Score Section (Very Prominent) | |
overall_score = _calculate_overall_score(report_data) | |
score_color = _get_score_color(overall_score['label']) | |
# Large score display box | |
score_box = FancyBboxPatch( | |
(LEFT_MARGIN, y_pos - 1.5), CONTENT_WIDTH, 1.5, | |
boxstyle="round,pad=0.08", | |
facecolor=score_color, | |
alpha=0.1, | |
edgecolor=score_color, | |
linewidth=3 | |
) | |
ax.add_patch(score_box) | |
# Score circle indicator | |
score_circle = Circle((LEFT_MARGIN + 0.8, y_pos - 0.75), 0.5, | |
facecolor=score_color, alpha=0.2, | |
edgecolor=score_color, linewidth=3) | |
ax.add_patch(score_circle) | |
# Score percentage in circle | |
ax.text(LEFT_MARGIN + 0.8, y_pos - 0.75, f"{overall_score['percentage']:.0f}%", | |
fontsize=20, fontweight='bold', color=score_color, | |
horizontalalignment='center', verticalalignment='center') | |
# Score label | |
ax.text(LEFT_MARGIN + 1.8, y_pos - 0.4, 'OVERALL ASSESSMENT', | |
fontsize=10, color='#6b7280', fontweight='bold') | |
ax.text(LEFT_MARGIN + 1.8, y_pos - 0.8, overall_score['label'].upper(), | |
fontsize=24, fontweight='bold', color=score_color) | |
# Score description | |
score_desc = _get_score_description(overall_score['label']) | |
ax.text(LEFT_MARGIN + 1.8, y_pos - 1.1, score_desc, | |
fontsize=10, color='#6b7280', style='italic') | |
y_pos -= 2.0 | |
# Candidate Information Section | |
_add_section_header(ax, LEFT_MARGIN, y_pos, 'CANDIDATE PROFILE', CONTENT_WIDTH) | |
y_pos -= 0.5 | |
# Professional info grid with better spacing | |
info_items = [ | |
('Full Name', report_data['candidate_name']), | |
('Email Address', report_data['candidate_email']), | |
('Applied Position', report_data['job_role']), | |
('Company', report_data['company']), | |
('Application Date', report_data['date_applied']) | |
] | |
# Create info boxes | |
for i, (label, value) in enumerate(info_items): | |
y_offset = y_pos - (i * 0.35) | |
# Label | |
ax.text(LEFT_MARGIN + 0.1, y_offset, f"{label}:", | |
fontsize=9, color='#6b7280', fontweight='bold') | |
# Value with background | |
value_rect = FancyBboxPatch( | |
(LEFT_MARGIN + 2.2, y_offset - 0.1), CONTENT_WIDTH - 2.3, 0.25, | |
boxstyle="round,pad=0.02", | |
facecolor='#f8fafc', | |
edgecolor='#e2e8f0', | |
linewidth=0.5 | |
) | |
ax.add_patch(value_rect) | |
ax.text(LEFT_MARGIN + 2.3, y_offset, value, | |
fontsize=10, color='#111827', fontweight='bold') | |
y_pos -= 2.2 | |
# Skills Analysis Section | |
_add_section_header(ax, LEFT_MARGIN, y_pos, 'SKILLS ANALYSIS', CONTENT_WIDTH) | |
y_pos -= 0.5 | |
skills_data = report_data['skills_match'] | |
# Skills match visualization with better design | |
bar_x = LEFT_MARGIN + 0.1 | |
bar_y = y_pos - 0.4 | |
bar_width = CONTENT_WIDTH - 0.2 | |
bar_height = 0.4 | |
# Background bar with rounded corners | |
bg_bar = FancyBboxPatch( | |
(bar_x, bar_y), bar_width, bar_height, | |
boxstyle="round,pad=0.02", | |
facecolor='#f1f5f9', | |
edgecolor='#cbd5e1', | |
linewidth=1 | |
) | |
ax.add_patch(bg_bar) | |
# Progress bar | |
progress_width = max(0.3, bar_width * (skills_data['ratio'] / 100)) # Minimum visible width | |
progress_color = _get_score_color(skills_data['score']) | |
progress_bar = FancyBboxPatch( | |
(bar_x, bar_y), progress_width, bar_height, | |
boxstyle="round,pad=0.02", | |
facecolor=progress_color, | |
edgecolor='none' | |
) | |
ax.add_patch(progress_bar) | |
# Percentage text with better positioning | |
ax.text(bar_x + bar_width/2, bar_y + bar_height/2, | |
f"{skills_data['ratio']:.0f}% SKILLS MATCH", | |
fontsize=12, fontweight='bold', color='white', | |
horizontalalignment='center', verticalalignment='center') | |
y_pos -= 0.9 | |
# Skills details with better formatting | |
skills_items = [ | |
('Required Skills', skills_data['required'], '#dc2626'), | |
('Candidate Skills', skills_data['candidate'], '#2563eb'), | |
('Matching Skills', skills_data['common'], '#059669') | |
] | |
for i, (label, value, color) in enumerate(skills_items): | |
y_offset = y_pos - (i * 0.6) | |
# Color indicator | |
indicator = Circle((LEFT_MARGIN + 0.15, y_offset), 0.08, | |
facecolor=color, edgecolor='none') | |
ax.add_patch(indicator) | |
# Label | |
ax.text(LEFT_MARGIN + 0.35, y_offset, f"{label}:", | |
fontsize=10, color='#374151', fontweight='bold') | |
# Wrap and display skills | |
if value and value != 'N/A': | |
wrapped_value = textwrap.fill(value, width=65) | |
lines = wrapped_value.split('\n') | |
for j, line in enumerate(lines[:2]): # Max 2 lines | |
ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25 - (j * 0.2), line, | |
fontsize=9, color='#6b7280') | |
else: | |
ax.text(LEFT_MARGIN + 0.35, y_offset - 0.25, 'None specified', | |
fontsize=9, color='#9ca3af', style='italic') | |
# Save first page | |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0) | |
plt.close(fig) | |
# Page 2+: Interview Transcript | |
if report_data['qa_log']: | |
_create_transcript_pages(pdf, report_data['qa_log'], A4_WIDTH, A4_HEIGHT, | |
LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN) | |
buffer.seek(0) | |
return buffer | |
def _parse_report_text(report_text: str) -> Dict[str, Any]: | |
"""Parse the text report into structured data.""" | |
lines = report_text.split('\n') | |
data = { | |
'candidate_name': 'N/A', | |
'candidate_email': 'N/A', | |
'job_role': 'N/A', | |
'company': 'N/A', | |
'date_applied': 'N/A', | |
'skills_match': { | |
'required': 'N/A', | |
'candidate': 'N/A', | |
'common': 'N/A', | |
'ratio': 0, | |
'score': 'N/A' | |
}, | |
'qa_log': [] | |
} | |
current_question = None | |
for line in lines: | |
line = line.strip() | |
if line.startswith('Candidate Name:'): | |
data['candidate_name'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Candidate Email:'): | |
data['candidate_email'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Job Applied:'): | |
data['job_role'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Company:'): | |
data['company'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Date Applied:'): | |
data['date_applied'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Required Skills:'): | |
data['skills_match']['required'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Candidate Skills:'): | |
data['skills_match']['candidate'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Skills in Common:'): | |
data['skills_match']['common'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Match Ratio:'): | |
try: | |
data['skills_match']['ratio'] = float(line.split(':')[1].strip().rstrip('%')) | |
except: | |
data['skills_match']['ratio'] = 0 | |
elif line.startswith('Score:') and 'skills_match' in str(data): | |
data['skills_match']['score'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Question'): | |
if current_question: | |
data['qa_log'].append(current_question) | |
current_question = { | |
'question': line.split(':', 1)[1].strip() if ':' in line else line, | |
'answer': '', | |
'score': '', | |
'feedback': '' | |
} | |
elif line.startswith('Answer:') and current_question: | |
current_question['answer'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Score:') and current_question: | |
current_question['score'] = line.split(':', 1)[1].strip() | |
elif line.startswith('Feedback:') and current_question: | |
current_question['feedback'] = line.split(':', 1)[1].strip() | |
if current_question: | |
data['qa_log'].append(current_question) | |
return data | |
def _calculate_overall_score(report_data: Dict[str, Any]) -> Dict[str, Any]: | |
"""Calculate overall score from skills match and QA scores.""" | |
# Skills match contributes 40% | |
skills_ratio = report_data['skills_match']['ratio'] / 100 | |
# QA scores contribute 60% | |
qa_scores = [] | |
for qa in report_data['qa_log']: | |
score_text = qa['score'].lower() | |
if 'excellent' in score_text or '5' in score_text or '10' in score_text: | |
qa_scores.append(1.0) | |
elif 'good' in score_text or '4' in score_text or '8' in score_text or '9' in score_text: | |
qa_scores.append(0.8) | |
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: | |
qa_scores.append(0.6) | |
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: | |
qa_scores.append(0.4) | |
else: | |
qa_scores.append(0.2) | |
qa_average = sum(qa_scores) / len(qa_scores) if qa_scores else 0.5 | |
# Calculate weighted average | |
overall = (skills_ratio * 0.4) + (qa_average * 0.6) | |
percentage = overall * 100 | |
if overall >= 0.8: | |
label = 'Excellent' | |
elif overall >= 0.65: | |
label = 'Good' | |
elif overall >= 0.45: | |
label = 'Satisfactory' | |
else: | |
label = 'Needs Improvement' | |
return {'percentage': percentage, 'label': label} | |
def _get_score_color(score_label: str) -> str: | |
"""Get color based on score label.""" | |
score_label = score_label.lower() | |
if 'excellent' in score_label: | |
return '#059669' # Green | |
elif 'good' in score_label: | |
return '#2563eb' # Blue | |
elif 'medium' in score_label or 'satisfactory' in score_label: | |
return '#d97706' # Orange | |
else: | |
return '#dc2626' # Red | |
def _get_score_description(score_label: str) -> str: | |
"""Get description based on score label.""" | |
descriptions = { | |
'Excellent': 'Outstanding candidate with strong alignment', | |
'Good': 'Solid candidate with good potential', | |
'Satisfactory': 'Adequate candidate with room for growth', | |
'Needs Improvement': 'Candidate requires significant development' | |
} | |
return descriptions.get(score_label, 'Assessment completed') | |
def _add_section_header(ax, x: float, y: float, title: str, width: float): | |
"""Add a professional section header with separator line.""" | |
# Background for header | |
header_bg = FancyBboxPatch( | |
(x - 0.05, y - 0.05), width + 0.1, 0.35, | |
boxstyle="round,pad=0.02", | |
facecolor='#f8fafc', | |
edgecolor='#e2e8f0', | |
linewidth=1 | |
) | |
ax.add_patch(header_bg) | |
# Title | |
ax.text(x + 0.1, y + 0.1, title, fontsize=12, fontweight='bold', color='#1e293b') | |
# Decorative line | |
line = plt.Line2D([x + 0.1, x + width - 0.1], [y - 0.15, y - 0.15], | |
color='#3b82f6', linewidth=2) | |
ax.add_line(line) | |
def _create_transcript_pages(pdf, qa_log: List[Dict], page_width: float, page_height: float, | |
left_margin: float, right_margin: float, | |
top_margin: float, bottom_margin: float): | |
"""Create professional pages for interview transcript.""" | |
content_width = page_width - left_margin - right_margin | |
wrapper = textwrap.TextWrapper(width=75) | |
# Group questions for pagination | |
questions_per_page = 2 # Reduced for better spacing | |
total_pages = (len(qa_log) + questions_per_page - 1) // questions_per_page | |
for page_num in range(total_pages): | |
fig = plt.figure(figsize=(page_width, page_height)) | |
fig.patch.set_facecolor('white') | |
ax = fig.add_subplot(111) | |
ax.set_xlim(0, page_width) | |
ax.set_ylim(0, page_height) | |
ax.axis('off') | |
# Page header | |
y_pos = page_height - top_margin | |
# Header background | |
header_rect = FancyBboxPatch( | |
(left_margin, y_pos - 0.6), content_width, 0.6, | |
boxstyle="round,pad=0.02", | |
facecolor='#1e40af', | |
edgecolor='none' | |
) | |
ax.add_patch(header_rect) | |
ax.text(left_margin + 0.2, y_pos - 0.3, 'INTERVIEW TRANSCRIPT', | |
fontsize=14, fontweight='bold', color='white') | |
# Page number | |
ax.text(page_width - right_margin - 0.2, y_pos - 0.3, f'Page {page_num + 2}', | |
fontsize=10, color='white', horizontalalignment='right') | |
y_pos -= 1.0 | |
# Questions for this page | |
start_idx = page_num * questions_per_page | |
end_idx = min(start_idx + questions_per_page, len(qa_log)) | |
for i in range(start_idx, end_idx): | |
qa = qa_log[i] | |
# Question section | |
q_box = FancyBboxPatch( | |
(left_margin, y_pos - 1.0), content_width, 1.0, | |
boxstyle="round,pad=0.05", | |
facecolor='#eff6ff', | |
edgecolor='#3b82f6', | |
linewidth=2 | |
) | |
ax.add_patch(q_box) | |
# Question number badge | |
q_badge = Circle((left_margin + 0.4, y_pos - 0.5), 0.2, | |
facecolor='#3b82f6', edgecolor='white', linewidth=2) | |
ax.add_patch(q_badge) | |
ax.text(left_margin + 0.4, y_pos - 0.5, f'{i+1}', | |
fontsize=12, fontweight='bold', color='white', | |
horizontalalignment='center', verticalalignment='center') | |
# Question text | |
ax.text(left_margin + 0.8, y_pos - 0.3, 'QUESTION', | |
fontsize=9, fontweight='bold', color='#1e40af') | |
q_wrapped = wrapper.wrap(qa['question']) | |
for j, line in enumerate(q_wrapped[:3]): # Max 3 lines | |
ax.text(left_margin + 0.8, y_pos - 0.5 - (j * 0.15), line, | |
fontsize=11, fontweight='bold', color='#1e293b') | |
y_pos -= 1.4 | |
# Answer section | |
answer_box = FancyBboxPatch( | |
(left_margin + 0.2, y_pos - 1.2), content_width - 0.4, 1.2, | |
boxstyle="round,pad=0.05", | |
facecolor='#f9fafb', | |
edgecolor='#d1d5db', | |
linewidth=1 | |
) | |
ax.add_patch(answer_box) | |
ax.text(left_margin + 0.4, y_pos - 0.2, 'CANDIDATE RESPONSE', | |
fontsize=9, fontweight='bold', color='#6b7280') | |
a_wrapped = wrapper.wrap(qa['answer']) | |
for j, line in enumerate(a_wrapped[:4]): # Max 4 lines | |
ax.text(left_margin + 0.4, y_pos - 0.4 - (j * 0.15), line, | |
fontsize=10, color='#374151') | |
y_pos -= 1.6 | |
# Evaluation section | |
eval_box = FancyBboxPatch( | |
(left_margin + 0.4, y_pos - 0.8), content_width - 0.8, 0.8, | |
boxstyle="round,pad=0.05", | |
facecolor='#fefefe', | |
edgecolor='#e5e7eb', | |
linewidth=1 | |
) | |
ax.add_patch(eval_box) | |
# Score badge | |
score_color = _get_score_color(qa['score']) | |
score_badge = FancyBboxPatch( | |
(left_margin + 0.6, y_pos - 0.35), 1.2, 0.25, | |
boxstyle="round,pad=0.02", | |
facecolor=score_color, | |
alpha=0.2, | |
edgecolor=score_color, | |
linewidth=1 | |
) | |
ax.add_patch(score_badge) | |
ax.text(left_margin + 1.2, y_pos - 0.225, qa['score'], | |
fontsize=10, fontweight='bold', color=score_color, | |
horizontalalignment='center', verticalalignment='center') | |
# Feedback | |
if qa['feedback'] and qa['feedback'] != 'N/A': | |
ax.text(left_margin + 2.2, y_pos - 0.15, 'Feedback:', | |
fontsize=9, fontweight='bold', color='#6b7280') | |
f_wrapped = wrapper.wrap(qa['feedback']) | |
for j, line in enumerate(f_wrapped[:2]): # Max 2 lines | |
ax.text(left_margin + 2.2, y_pos - 0.35 - (j * 0.15), line, | |
fontsize=9, color='#6b7280', style='italic') | |
y_pos -= 1.2 | |
# Add separator between questions (except last) | |
if i < end_idx - 1: | |
separator = plt.Line2D([left_margin + 1, left_margin + content_width - 1], | |
[y_pos + 0.3, y_pos + 0.3], | |
color='#e5e7eb', linewidth=1, linestyle='--') | |
ax.add_line(separator) | |
y_pos -= 0.3 | |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0) | |
plt.close(fig) | |
# Keep the original advanced version as fallback | |
def create_pdf_report_advanced(report_text: str) -> BytesIO: | |
""" | |
Alternative implementation using reportlab for professional PDF generation. | |
This creates cleaner, more consistent PDFs with better text handling. | |
Install with: pip install reportlab | |
""" | |
try: | |
from reportlab.lib.pagesizes import A4 | |
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
from reportlab.lib.units import inch | |
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak | |
from reportlab.lib.colors import HexColor | |
from reportlab.lib.enums import TA_LEFT, TA_CENTER | |
buffer = BytesIO() | |
# Create document with consistent margins | |
doc = SimpleDocTemplate( | |
buffer, | |
pagesize=A4, | |
rightMargin=0.75*inch, | |
leftMargin=0.75*inch, | |
topMargin=1*inch, | |
bottomMargin=1*inch | |
) | |
# Define consistent styles | |
styles = getSampleStyleSheet() | |
# Title style | |
title_style = ParagraphStyle( | |
'CustomTitle', | |
parent=styles['Heading1'], | |
fontSize=16, | |
textColor=HexColor('#111827'), | |
spaceAfter=12, | |
alignment=TA_CENTER | |
) | |
# Header style for metadata | |
header_style = ParagraphStyle( | |
'Header', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#111827'), | |
spaceAfter=4 | |
) | |
# Question style | |
question_style = ParagraphStyle( | |
'Question', | |
parent=styles['Heading2'], | |
fontSize=11, | |
textColor=HexColor('#1e3a8a'), | |
spaceBefore=12, | |
spaceAfter=6, | |
fontName='Helvetica-Bold' | |
) | |
# Answer style | |
answer_style = ParagraphStyle( | |
'Answer', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#374151'), | |
leftIndent=20, | |
spaceAfter=4 | |
) | |
# Score style | |
score_style = ParagraphStyle( | |
'Score', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#059669'), | |
leftIndent=20, | |
fontName='Helvetica-Bold', | |
spaceAfter=4 | |
) | |
# Feedback style | |
feedback_style = ParagraphStyle( | |
'Feedback', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#dc2626'), | |
leftIndent=20, | |
spaceAfter=8 | |
) | |
# Build document content | |
story = [] | |
lines = report_text.split('\n') | |
for line in lines: | |
stripped = line.strip() | |
if not stripped: | |
story.append(Spacer(1, 6)) | |
elif 'Interview Report' in stripped: | |
story.append(Paragraph(stripped, title_style)) | |
story.append(Spacer(1, 12)) | |
elif any(stripped.startswith(x) for x in ['Candidate Name:', 'Candidate Email:', 'Job Applied:', 'Company:', 'Date Applied:']): | |
story.append(Paragraph(stripped, header_style)) | |
elif stripped.startswith('Skills Match Summary:') or stripped.startswith('Interview Transcript'): | |
story.append(Spacer(1, 12)) | |
story.append(Paragraph(f"<b>{stripped}</b>", header_style)) | |
story.append(Spacer(1, 6)) | |
elif stripped.startswith('Question'): | |
story.append(Paragraph(stripped, question_style)) | |
elif stripped.startswith('Answer:'): | |
story.append(Paragraph(stripped, answer_style)) | |
elif stripped.startswith('Score:'): | |
story.append(Paragraph(stripped, score_style)) | |
elif stripped.startswith('Feedback:'): | |
story.append(Paragraph(stripped, feedback_style)) | |
else: | |
# Regular text with proper indentation for sub-items | |
if stripped.startswith(' '): | |
indent_style = ParagraphStyle( | |
'Indented', | |
parent=styles['Normal'], | |
fontSize=10, | |
leftIndent=20, | |
spaceAfter=2 | |
) | |
story.append(Paragraph(stripped, indent_style)) | |
else: | |
story.append(Paragraph(stripped, styles['Normal'])) | |
# Build PDF | |
doc.build(story) | |
buffer.seek(0) | |
return buffer | |
except ImportError: | |
# Fallback to matplotlib version if reportlab not available | |
print("Reportlab not installed. Using matplotlib version.") | |
return create_pdf_report(report_text) | |
__all__ = ['generate_llm_interview_report', 'create_pdf_report'] | |