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 | |
import matplotlib.pyplot as plt | |
from matplotlib.backends.backend_pdf import PdfPages | |
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) | |
from io import BytesIO | |
from matplotlib.backends.backend_pdf import PdfPages | |
import matplotlib.pyplot as plt | |
import matplotlib.patches as mpatches | |
from typing import List, Tuple | |
import textwrap | |
def create_pdf_report(report_text: str) -> BytesIO: | |
"""Convert a formatted report into a clean, well-organized PDF.""" | |
from matplotlib.backends.backend_pdf import PdfPages | |
import matplotlib.pyplot as plt | |
import matplotlib.patches as mpatches | |
from io import BytesIO | |
import textwrap | |
buffer = BytesIO() | |
# Page configuration - A4 size | |
PAGE_WIDTH = 8.27 # A4 width in inches | |
PAGE_HEIGHT = 11.69 # A4 height in inches | |
MARGIN = 0.75 # Uniform margins | |
# Text configuration | |
CHARS_PER_LINE = 80 # Characters per line for wrapping | |
LINES_PER_PAGE = 45 # Lines that fit on one page | |
# Colors | |
COLORS = { | |
'question': '#1e3a8a', # Dark blue | |
'answer': '#374151', # Dark gray | |
'score': '#059669', # Green | |
'feedback': '#dc2626', # Red | |
'header': '#111827', # Almost black | |
'normal': '#374151' # Gray | |
} | |
# Process the text | |
lines = report_text.split('\n') | |
processed_lines = [] | |
wrapper = textwrap.TextWrapper(width=CHARS_PER_LINE, break_long_words=False) | |
for line in lines: | |
if not line.strip(): | |
processed_lines.append({'text': '', 'type': 'blank'}) | |
continue | |
# Identify line type | |
if line.strip().startswith('Question'): | |
# Add spacing before questions (except first) | |
if processed_lines and processed_lines[-1]['type'] != 'blank': | |
processed_lines.append({'text': '', 'type': 'blank'}) | |
processed_lines.append({ | |
'text': line.strip(), | |
'type': 'question', | |
'size': 11, | |
'bold': True | |
}) | |
elif line.strip().startswith('Answer:'): | |
wrapped = wrapper.wrap(line.strip()) | |
for i, wrapped_line in enumerate(wrapped): | |
processed_lines.append({ | |
'text': (' ' + wrapped_line) if i == 0 else (' ' + wrapped_line), | |
'type': 'answer', | |
'size': 10 | |
}) | |
elif line.strip().startswith('Score:'): | |
processed_lines.append({ | |
'text': ' ' + line.strip(), | |
'type': 'score', | |
'size': 10, | |
'bold': True | |
}) | |
elif line.strip().startswith('Feedback:'): | |
wrapped = wrapper.wrap(line.strip()) | |
for i, wrapped_line in enumerate(wrapped): | |
processed_lines.append({ | |
'text': (' ' + wrapped_line) if i == 0 else (' ' + wrapped_line), | |
'type': 'feedback', | |
'size': 10 | |
}) | |
elif any(line.strip().startswith(x) for x in ['Interview Report', 'Candidate Name:', 'Candidate Email:', | |
'Job Applied:', 'Company:', 'Date Applied:', | |
'Skills Match Summary:', 'Interview Transcript']): | |
# Headers and metadata | |
if 'Interview Report' in line: | |
processed_lines.append({ | |
'text': line.strip(), | |
'type': 'header', | |
'size': 14, | |
'bold': True | |
}) | |
processed_lines.append({'text': '=' * 50, 'type': 'header', 'size': 10}) | |
else: | |
wrapped = wrapper.wrap(line.strip()) | |
for wrapped_line in enumerate(wrapped): | |
processed_lines.append({ | |
'text': wrapped_line[1], | |
'type': 'header', | |
'size': 10, | |
'bold': True if ':' in wrapped_line[1] and wrapped_line[0] == 0 else False | |
}) | |
else: | |
# Regular text | |
wrapped = wrapper.wrap(line) | |
for wrapped_line in wrapped: | |
processed_lines.append({ | |
'text': wrapped_line, | |
'type': 'normal', | |
'size': 10 | |
}) | |
# Create PDF with consistent pages | |
with PdfPages(buffer) as pdf: | |
page_count = 0 | |
line_index = 0 | |
while line_index < len(processed_lines): | |
# Create new page | |
fig = plt.figure(figsize=(PAGE_WIDTH, PAGE_HEIGHT)) | |
fig.patch.set_facecolor('white') | |
ax = fig.add_subplot(111) | |
ax.axis('off') | |
ax.set_xlim(0, 1) | |
ax.set_ylim(0, 1) | |
# Add subtle page border | |
border = mpatches.Rectangle( | |
(MARGIN/PAGE_WIDTH, MARGIN/PAGE_HEIGHT), | |
1 - 2*MARGIN/PAGE_WIDTH, | |
1 - 2*MARGIN/PAGE_HEIGHT, | |
fill=False, | |
edgecolor='#e5e7eb', | |
linewidth=1 | |
) | |
ax.add_patch(border) | |
# Current y position (start from top) | |
y_pos = 1 - MARGIN/PAGE_HEIGHT - 0.05 | |
lines_on_page = 0 | |
# Add content to page | |
while line_index < len(processed_lines) and lines_on_page < LINES_PER_PAGE: | |
line_data = processed_lines[line_index] | |
# Skip if too close to bottom | |
if y_pos < MARGIN/PAGE_HEIGHT + 0.05: | |
break | |
# Set text properties | |
color = COLORS.get(line_data['type'], COLORS['normal']) | |
size = line_data.get('size', 10) | |
weight = 'bold' if line_data.get('bold', False) else 'normal' | |
# Add text | |
ax.text( | |
MARGIN/PAGE_WIDTH + 0.02, | |
y_pos, | |
line_data['text'], | |
transform=ax.transAxes, | |
fontsize=size, | |
fontweight=weight, | |
color=color, | |
fontfamily='sans-serif', | |
verticalalignment='top' | |
) | |
# Move to next line | |
line_height = 0.018 if line_data['type'] == 'blank' else 0.022 | |
y_pos -= line_height | |
lines_on_page += 1 | |
line_index += 1 | |
# Add page number at bottom | |
page_count += 1 | |
ax.text( | |
0.5, | |
MARGIN/PAGE_HEIGHT - 0.03, | |
f'Page {page_count}', | |
transform=ax.transAxes, | |
fontsize=9, | |
color='#9ca3af', | |
horizontalalignment='center' | |
) | |
# Save page | |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0.1) | |
plt.close(fig) | |
buffer.seek(0) | |
return buffer | |
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'] |