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'] |