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) | |
def create_pdf_report(report_text: str) -> BytesIO: | |
"""Convert a formatted report into a visually clear PDF with bold Qs and indented As.""" | |
buffer = BytesIO() | |
# Prepare wrapped lines | |
raw_lines = report_text.split("\n") | |
wrapper = textwrap.TextWrapper(width=85, break_long_words=True, replace_whitespace=False) | |
formatted_lines: List[str] = [] | |
for line in raw_lines: | |
# Highlight Questions | |
if line.strip().startswith("Question"): | |
formatted_lines.append("") # Extra spacing before new question | |
formatted_lines.append(f"**{line.strip()}**") # Bold style marker | |
elif line.strip().startswith("Answer:"): | |
# Indent answers for clarity | |
answer_text = line.replace("Answer:", "").strip() | |
wrapped_answer = wrapper.wrap(answer_text) | |
formatted_lines.append(f" Answer: {wrapped_answer[0]}") | |
for extra in wrapped_answer[1:]: | |
formatted_lines.append(f" {extra}") | |
elif line.strip().startswith("Score:") or line.strip().startswith("Feedback:"): | |
# Keep score & feedback on separate lines | |
formatted_lines.append(f" {line.strip()}") | |
else: | |
# Wrap other lines normally | |
wrapped = wrapper.wrap(line) | |
formatted_lines.extend(wrapped if wrapped else [""]) | |
# PDF creation | |
lines_per_page = 38 | |
with PdfPages(buffer) as pdf: | |
for i in range(0, len(formatted_lines), lines_per_page): | |
fig = plt.figure(figsize=(8.27, 11.69)) # A4 | |
fig.patch.set_facecolor('white') | |
page_text = "\n".join(formatted_lines[i:i + lines_per_page]) | |
fig.text(0.1, 0.95, page_text, va="top", ha="left", fontsize=10, family="monospace") | |
pdf.savefig(fig) | |
plt.close(fig) | |
buffer.seek(0) | |
return buffer | |
__all__ = ['generate_llm_interview_report', 'create_pdf_report'] |