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 visually appealing PDF with enhanced formatting.""" | |
buffer = BytesIO() | |
# Configuration | |
PAGE_WIDTH = 8.27 # A4 width in inches | |
PAGE_HEIGHT = 11.69 # A4 height in inches | |
MARGIN_LEFT = 0.75 | |
MARGIN_RIGHT = 0.75 | |
MARGIN_TOP = 1.0 | |
MARGIN_BOTTOM = 1.0 | |
# Calculate usable width for text | |
usable_width = PAGE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT | |
chars_per_inch = 12 # Approximate for 10pt font | |
wrap_width = int(usable_width * chars_per_inch) | |
# Text styling | |
FONT_SIZE_NORMAL = 10 | |
FONT_SIZE_HEADING = 12 | |
LINE_HEIGHT = 0.02 # Relative to page height | |
QUESTION_COLOR = '#2C3E50' # Dark blue-gray | |
ANSWER_COLOR = '#34495E' # Slightly lighter | |
SCORE_COLOR = '#27AE60' # Green | |
FEEDBACK_COLOR = '#E74C3C' # Red | |
# Prepare formatted content | |
raw_lines = report_text.split("\n") | |
wrapper = textwrap.TextWrapper(width=wrap_width, break_long_words=False, replace_whitespace=False) | |
formatted_content = [] | |
for line in raw_lines: | |
stripped = line.strip() | |
if stripped.startswith("Question"): | |
# Add spacing before questions (except the first one) | |
if formatted_content: | |
formatted_content.append({"text": "", "style": "normal"}) | |
# Question with special formatting | |
formatted_content.append({ | |
"text": stripped, | |
"style": "question", | |
"color": QUESTION_COLOR, | |
"bold": True, | |
"size": FONT_SIZE_HEADING | |
}) | |
elif stripped.startswith("Answer:"): | |
# Extract and wrap answer text | |
answer_text = stripped.replace("Answer:", "", 1).strip() | |
wrapped_lines = wrapper.wrap(f"Answer: {answer_text}") if answer_text else ["Answer:"] | |
for idx, wrapped_line in enumerate(wrapped_lines): | |
formatted_content.append({ | |
"text": " " + wrapped_line if idx == 0 else " " + wrapped_line, | |
"style": "answer", | |
"color": ANSWER_COLOR, | |
"size": FONT_SIZE_NORMAL | |
}) | |
elif stripped.startswith("Score:"): | |
formatted_content.append({ | |
"text": f" {stripped}", | |
"style": "score", | |
"color": SCORE_COLOR, | |
"bold": True, | |
"size": FONT_SIZE_NORMAL | |
}) | |
elif stripped.startswith("Feedback:"): | |
# Wrap feedback text | |
feedback_text = stripped.replace("Feedback:", "", 1).strip() | |
wrapped_lines = wrapper.wrap(f"Feedback: {feedback_text}") if feedback_text else ["Feedback:"] | |
for idx, wrapped_line in enumerate(wrapped_lines): | |
formatted_content.append({ | |
"text": " " + wrapped_line if idx == 0 else " " + wrapped_line, | |
"style": "feedback", | |
"color": FEEDBACK_COLOR, | |
"size": FONT_SIZE_NORMAL | |
}) | |
else: | |
# Regular text | |
if stripped: | |
wrapped_lines = wrapper.wrap(line) | |
for wrapped_line in wrapped_lines: | |
formatted_content.append({ | |
"text": wrapped_line, | |
"style": "normal", | |
"color": "black", | |
"size": FONT_SIZE_NORMAL | |
}) | |
else: | |
formatted_content.append({"text": "", "style": "normal"}) | |
# Calculate lines per page based on actual line height | |
usable_height = PAGE_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM | |
lines_per_page = int(usable_height / (LINE_HEIGHT * PAGE_HEIGHT)) | |
# Create PDF | |
with PdfPages(buffer) as pdf: | |
page_start = 0 | |
page_num = 1 | |
while page_start < len(formatted_content): | |
# Create figure | |
fig = plt.figure(figsize=(PAGE_WIDTH, PAGE_HEIGHT)) | |
fig.patch.set_facecolor('white') | |
ax = fig.add_subplot(111) | |
ax.axis('off') | |
# Add subtle page border | |
border = mpatches.Rectangle( | |
(0.5, 0.5), PAGE_WIDTH - 1, PAGE_HEIGHT - 1, | |
fill=False, edgecolor='#BDC3C7', linewidth=0.5 | |
) | |
ax.add_patch(border) | |
# Add page content | |
y_position = 1 - MARGIN_TOP / PAGE_HEIGHT | |
lines_on_page = 0 | |
for idx in range(page_start, min(page_start + lines_per_page, len(formatted_content))): | |
item = formatted_content[idx] | |
# Apply text styling | |
weight = 'bold' if item.get('bold', False) else 'normal' | |
size = item.get('size', FONT_SIZE_NORMAL) | |
color = item.get('color', 'black') | |
# Add text to page | |
ax.text( | |
MARGIN_LEFT / PAGE_WIDTH, | |
y_position, | |
item['text'], | |
transform=ax.transAxes, | |
fontsize=size, | |
fontweight=weight, | |
color=color, | |
fontfamily='DejaVu Sans', | |
verticalalignment='top' | |
) | |
# Move to next line | |
y_position -= LINE_HEIGHT | |
lines_on_page += 1 | |
# Check if we need a new page (with some buffer) | |
if y_position < MARGIN_BOTTOM / PAGE_HEIGHT + 0.05: | |
break | |
# Add page number | |
ax.text( | |
0.5, | |
MARGIN_BOTTOM / PAGE_HEIGHT / 2, | |
f"Page {page_num}", | |
transform=ax.transAxes, | |
fontsize=9, | |
color='#7F8C8D', | |
horizontalalignment='center' | |
) | |
# Save page | |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0) | |
plt.close(fig) | |
# Move to next page | |
page_start += lines_on_page | |
page_num += 1 | |
buffer.seek(0) | |
return buffer | |
def create_pdf_report_advanced(report_text: str) -> BytesIO: | |
""" | |
Alternative implementation using reportlab for better PDF generation. | |
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 | |
buffer = BytesIO() | |
doc = SimpleDocTemplate( | |
buffer, | |
pagesize=A4, | |
rightMargin=0.75*inch, | |
leftMargin=0.75*inch, | |
topMargin=1*inch, | |
bottomMargin=1*inch | |
) | |
# Create custom styles | |
styles = getSampleStyleSheet() | |
question_style = ParagraphStyle( | |
'Question', | |
parent=styles['Heading2'], | |
fontSize=12, | |
textColor=HexColor('#2C3E50'), | |
spaceAfter=6, | |
spaceBefore=12 | |
) | |
answer_style = ParagraphStyle( | |
'Answer', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#34495E'), | |
leftIndent=20, | |
spaceAfter=3 | |
) | |
score_style = ParagraphStyle( | |
'Score', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#27AE60'), | |
leftIndent=20, | |
fontName='Helvetica-Bold' | |
) | |
feedback_style = ParagraphStyle( | |
'Feedback', | |
parent=styles['Normal'], | |
fontSize=10, | |
textColor=HexColor('#E74C3C'), | |
leftIndent=20, | |
spaceAfter=6 | |
) | |
# Build document content | |
story = [] | |
lines = report_text.split('\n') | |
for line in lines: | |
stripped = line.strip() | |
if 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)) | |
elif stripped: | |
story.append(Paragraph(stripped, styles['Normal'])) | |
else: | |
story.append(Spacer(1, 12)) | |
# Build PDF | |
doc.build(story) | |
buffer.seek(0) | |
return buffer | |
except ImportError: | |
# Fallback to matplotlib version | |
return create_pdf_report(report_text) | |
__all__ = ['generate_llm_interview_report', 'create_pdf_report'] |