Spaces:
Paused
Paused
Commit
·
b5d3943
1
Parent(s):
ad080c9
report added
Browse files- Dockerfile +2 -0
- backend/routes/interview_api.py +67 -0
- backend/services/report_generator.py +188 -0
- backend/templates/dashboard.html +38 -3
- backend/templates/post_job.html +2 -2
Dockerfile
CHANGED
@@ -1,7 +1,9 @@
|
|
1 |
# Use an NVIDIA PyTorch container with cuDNN 9.1 support
|
2 |
FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04
|
3 |
|
|
|
4 |
# Basic setup
|
|
|
5 |
ENV DEBIAN_FRONTEND=noninteractive
|
6 |
RUN apt-get update && apt-get install -y \
|
7 |
python3 python3-pip ffmpeg git libsndfile1 \
|
|
|
1 |
# Use an NVIDIA PyTorch container with cuDNN 9.1 support
|
2 |
FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04
|
3 |
|
4 |
+
|
5 |
# Basic setup
|
6 |
+
ENV OMP_NUM_THREADS=1
|
7 |
ENV DEBIAN_FRONTEND=noninteractive
|
8 |
RUN apt-get update && apt-get install -y \
|
9 |
python3 python3-pip ffmpeg git libsndfile1 \
|
backend/routes/interview_api.py
CHANGED
@@ -12,6 +12,11 @@ from backend.services.interview_engine import (
|
|
12 |
evaluate_answer
|
13 |
)
|
14 |
|
|
|
|
|
|
|
|
|
|
|
15 |
interview_api = Blueprint("interview_api", __name__)
|
16 |
|
17 |
@interview_api.route("/start_interview", methods=["POST"])
|
@@ -120,6 +125,68 @@ def transcribe_audio():
|
|
120 |
|
121 |
return jsonify({"transcript": transcript})
|
122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
@interview_api.route("/process_answer", methods=["POST"])
|
124 |
@login_required
|
125 |
def process_answer():
|
|
|
12 |
evaluate_answer
|
13 |
)
|
14 |
|
15 |
+
# Additional imports for report generation
|
16 |
+
from backend.models.database import Application
|
17 |
+
from backend.services.report_generator import generate_llm_interview_report, create_pdf_report
|
18 |
+
from flask import abort
|
19 |
+
|
20 |
interview_api = Blueprint("interview_api", __name__)
|
21 |
|
22 |
@interview_api.route("/start_interview", methods=["POST"])
|
|
|
125 |
|
126 |
return jsonify({"transcript": transcript})
|
127 |
|
128 |
+
# ----------------------------------------------------------------------------
|
129 |
+
# Interview report download
|
130 |
+
#
|
131 |
+
# Recruiters can download a PDF summarising a candidate's interview performance.
|
132 |
+
# This route performs several checks: it verifies that the current user has
|
133 |
+
# recruiter or admin privileges, ensures that the requested application exists
|
134 |
+
# and belongs to one of the recruiter's jobs, generates a textual report via
|
135 |
+
# the ``generate_llm_interview_report`` helper, converts it into a PDF, and
|
136 |
+
# finally sends the PDF as a file attachment. The heavy lifting is
|
137 |
+
# encapsulated in ``services/report_generator.py`` to keep this route
|
138 |
+
# lightweight.
|
139 |
+
@interview_api.route('/download_report/<int:application_id>', methods=['GET'])
|
140 |
+
@login_required
|
141 |
+
def download_report(application_id: int):
|
142 |
+
"""Generate and return a PDF report for a candidate's interview.
|
143 |
+
|
144 |
+
The ``application_id`` corresponds to the ID of the Application record
|
145 |
+
representing a candidate's job application. Only recruiters (or admins)
|
146 |
+
associated with the job are permitted to access this report.
|
147 |
+
"""
|
148 |
+
# Fetch the application or return 404 if not found
|
149 |
+
application = Application.query.get_or_404(application_id)
|
150 |
+
|
151 |
+
# Authorisation: ensure the current user is a recruiter or admin
|
152 |
+
if current_user.role not in ('recruiter', 'admin'):
|
153 |
+
# 403 Forbidden if the user lacks permissions
|
154 |
+
return abort(403)
|
155 |
+
|
156 |
+
# Further check that the recruiter owns the job unless admin
|
157 |
+
job = getattr(application, 'job', None)
|
158 |
+
if job is None:
|
159 |
+
return abort(404)
|
160 |
+
if current_user.role != 'admin' and job.recruiter_id != current_user.id:
|
161 |
+
return abort(403)
|
162 |
+
|
163 |
+
try:
|
164 |
+
# Generate the textual report using the helper function. At this
|
165 |
+
# stage, interview answers and evaluations are not stored server‑side,
|
166 |
+
# so the report focuses on the candidate's application data and
|
167 |
+
# computed skill match. Should answer/score data be persisted in
|
168 |
+
# future iterations, ``generate_llm_interview_report`` can be
|
169 |
+
# extended accordingly without touching this route.
|
170 |
+
report_text = generate_llm_interview_report(application)
|
171 |
+
|
172 |
+
# Convert the text to a PDF. The helper returns a BytesIO buffer
|
173 |
+
# ready for sending via Flask's ``send_file``. Matplotlib is used
|
174 |
+
# under the hood to avoid heavy dependencies like reportlab.
|
175 |
+
pdf_buffer = create_pdf_report(report_text)
|
176 |
+
pdf_buffer.seek(0)
|
177 |
+
|
178 |
+
filename = f"interview_report_{application.id}.pdf"
|
179 |
+
return send_file(
|
180 |
+
pdf_buffer,
|
181 |
+
download_name=filename,
|
182 |
+
as_attachment=True,
|
183 |
+
mimetype='application/pdf'
|
184 |
+
)
|
185 |
+
except Exception as exc:
|
186 |
+
# Log the error for debugging; return a 500 to the client
|
187 |
+
logging.error(f"Error generating report for application {application_id}: {exc}")
|
188 |
+
return jsonify({"error": "Failed to generate report"}), 500
|
189 |
+
|
190 |
@interview_api.route("/process_answer", methods=["POST"])
|
191 |
@login_required
|
192 |
def process_answer():
|
backend/services/report_generator.py
ADDED
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Utilities for assembling and exporting interview reports.
|
2 |
+
|
3 |
+
This module provides two primary helpers used by the recruiter dashboard:
|
4 |
+
|
5 |
+
``generate_llm_interview_report(application)``
|
6 |
+
Given a candidate's ``Application`` record, assemble a plain‑text report
|
7 |
+
summarising the interview. Because the interview process currently
|
8 |
+
executes entirely client‑side and does not persist questions or answers
|
9 |
+
to the database, this report focuses on the information available on
|
10 |
+
the server: the candidate's profile, the job requirements and a skills
|
11 |
+
match score. Should future iterations store richer interview data
|
12 |
+
server‑side, this function can be extended to include question/answer
|
13 |
+
transcripts, per‑question scores and LLM‑generated feedback.
|
14 |
+
|
15 |
+
``create_pdf_report(report_text)``
|
16 |
+
Convert a multi‑line string into a simple PDF. The implementation
|
17 |
+
leverages Matplotlib's PDF backend (available by default) to avoid
|
18 |
+
heavyweight dependencies such as ReportLab or WeasyPrint, which are
|
19 |
+
absent from the runtime environment. Text is wrapped and split
|
20 |
+
across multiple pages as necessary.
|
21 |
+
"""
|
22 |
+
|
23 |
+
from __future__ import annotations
|
24 |
+
|
25 |
+
import json
|
26 |
+
from io import BytesIO
|
27 |
+
import textwrap
|
28 |
+
from typing import List
|
29 |
+
|
30 |
+
import matplotlib.pyplot as plt
|
31 |
+
from matplotlib.backends.backend_pdf import PdfPages
|
32 |
+
|
33 |
+
def generate_llm_interview_report(application) -> str:
|
34 |
+
"""Generate a human‑readable interview report for a candidate.
|
35 |
+
|
36 |
+
The report includes the candidate's name and email, job details,
|
37 |
+
application date, a computed skills match summary and placeholder
|
38 |
+
sections for future enhancements. If server‑side storage of
|
39 |
+
question/answer pairs is added later, this function can be updated
|
40 |
+
to incorporate those details.
|
41 |
+
|
42 |
+
Parameters
|
43 |
+
----------
|
44 |
+
application : backend.models.database.Application
|
45 |
+
The SQLAlchemy Application instance representing the candidate's
|
46 |
+
job application. Assumed to have related ``job`` and
|
47 |
+
``date_applied`` attributes available.
|
48 |
+
|
49 |
+
Returns
|
50 |
+
-------
|
51 |
+
str
|
52 |
+
A multi‑line string containing the report contents.
|
53 |
+
"""
|
54 |
+
# Defensive imports to avoid circular dependencies at import time
|
55 |
+
try:
|
56 |
+
from datetime import datetime # noqa: F401
|
57 |
+
except Exception:
|
58 |
+
pass
|
59 |
+
|
60 |
+
# Extract candidate skills and job skills
|
61 |
+
try:
|
62 |
+
candidate_features = json.loads(application.extracted_features) if application.extracted_features else {}
|
63 |
+
except Exception:
|
64 |
+
candidate_features = {}
|
65 |
+
candidate_skills: List[str] = candidate_features.get('skills', []) or []
|
66 |
+
|
67 |
+
job_skills: List[str] = []
|
68 |
+
try:
|
69 |
+
job_skills = json.loads(application.job.skills) if application.job and application.job.skills else []
|
70 |
+
except Exception:
|
71 |
+
job_skills = []
|
72 |
+
|
73 |
+
# Compute skills match ratio and label. Normalise to lower case for
|
74 |
+
# comparison and avoid dividing by zero when ``job_skills`` is empty.
|
75 |
+
candidate_set = {s.strip().lower() for s in candidate_skills}
|
76 |
+
job_set = {s.strip().lower() for s in job_skills}
|
77 |
+
common = candidate_set & job_set
|
78 |
+
ratio = len(common) / len(job_set) if job_set else 0.0
|
79 |
+
|
80 |
+
if ratio >= 0.75:
|
81 |
+
score_label = 'Excellent'
|
82 |
+
elif ratio >= 0.5:
|
83 |
+
score_label = 'Good'
|
84 |
+
elif ratio >= 0.25:
|
85 |
+
score_label = 'Medium'
|
86 |
+
else:
|
87 |
+
score_label = 'Poor'
|
88 |
+
|
89 |
+
# Assemble report lines
|
90 |
+
lines: List[str] = []
|
91 |
+
lines.append('Interview Report')
|
92 |
+
lines.append('=================')
|
93 |
+
lines.append('')
|
94 |
+
lines.append(f'Candidate Name: {application.name}')
|
95 |
+
lines.append(f'Candidate Email: {application.email}')
|
96 |
+
if application.job:
|
97 |
+
lines.append(f'Job Applied: {application.job.role}')
|
98 |
+
lines.append(f'Company: {application.job.company}')
|
99 |
+
else:
|
100 |
+
lines.append('Job Applied: N/A')
|
101 |
+
lines.append('Company: N/A')
|
102 |
+
# Format date_applied if available
|
103 |
+
try:
|
104 |
+
date_str = application.date_applied.strftime('%Y-%m-%d') if application.date_applied else 'N/A'
|
105 |
+
except Exception:
|
106 |
+
date_str = 'N/A'
|
107 |
+
lines.append(f'Date Applied: {date_str}')
|
108 |
+
lines.append('')
|
109 |
+
lines.append('Skills Match Summary:')
|
110 |
+
# Represent required and candidate skills as comma‑separated lists. Use
|
111 |
+
# title‑case for presentation and handle empty lists gracefully.
|
112 |
+
formatted_job_skills = ', '.join(job_skills) if job_skills else 'N/A'
|
113 |
+
formatted_candidate_skills = ', '.join(candidate_skills) if candidate_skills else 'N/A'
|
114 |
+
formatted_common = ', '.join(sorted(common)) if common else 'None'
|
115 |
+
lines.append(f' Required Skills: {formatted_job_skills}')
|
116 |
+
lines.append(f' Candidate Skills: {formatted_candidate_skills}')
|
117 |
+
lines.append(f' Skills in Common: {formatted_common}')
|
118 |
+
lines.append(f' Match Ratio: {ratio * 100:.0f}%')
|
119 |
+
lines.append(f' Score: {score_label}')
|
120 |
+
lines.append('')
|
121 |
+
lines.append('Additional Notes:')
|
122 |
+
lines.append('This report was generated automatically based on the information provided in the application.')
|
123 |
+
lines.append('Interview question and answer details are not currently stored on the server.')
|
124 |
+
lines.append('Future versions may include a detailed breakdown of interview responses and evaluator feedback.')
|
125 |
+
|
126 |
+
return '\n'.join(lines)
|
127 |
+
|
128 |
+
|
129 |
+
def create_pdf_report(report_text: str) -> BytesIO:
|
130 |
+
"""Convert a plain‑text report into a PDF document.
|
131 |
+
|
132 |
+
This helper uses Matplotlib's ``PdfPages`` backend to compose a PDF
|
133 |
+
containing the supplied text. Lines are wrapped to a reasonable
|
134 |
+
width and paginated as needed. The returned ``BytesIO`` object can
|
135 |
+
be passed directly to Flask's ``send_file`` function.
|
136 |
+
|
137 |
+
Parameters
|
138 |
+
----------
|
139 |
+
report_text : str
|
140 |
+
The full contents of the report to be included in the PDF.
|
141 |
+
|
142 |
+
Returns
|
143 |
+
-------
|
144 |
+
BytesIO
|
145 |
+
A file‑like buffer containing the PDF data. The caller is
|
146 |
+
responsible for rewinding the buffer (via ``seek(0)``) before
|
147 |
+
sending it over HTTP.
|
148 |
+
"""
|
149 |
+
buffer = BytesIO()
|
150 |
+
|
151 |
+
# Split the input into lines and wrap long lines for better layout
|
152 |
+
raw_lines = report_text.split('\n')
|
153 |
+
wrapper = textwrap.TextWrapper(width=90, break_long_words=True, replace_whitespace=False)
|
154 |
+
wrapped_lines: List[str] = []
|
155 |
+
for line in raw_lines:
|
156 |
+
if not line:
|
157 |
+
wrapped_lines.append('')
|
158 |
+
continue
|
159 |
+
# Wrap each line individually; the wrapper returns a list
|
160 |
+
segments = wrapper.wrap(line)
|
161 |
+
if segments:
|
162 |
+
wrapped_lines.extend(segments)
|
163 |
+
else:
|
164 |
+
wrapped_lines.append(line)
|
165 |
+
|
166 |
+
# Determine how many lines to place on each PDF page. The value
|
167 |
+
# of 40 lines per page fits comfortably on an A4 sheet using the
|
168 |
+
# selected font size and margins.
|
169 |
+
lines_per_page = 40
|
170 |
+
|
171 |
+
with PdfPages(buffer) as pdf:
|
172 |
+
for i in range(0, len(wrapped_lines), lines_per_page):
|
173 |
+
fig = plt.figure(figsize=(8.27, 11.69)) # A4 portrait in inches
|
174 |
+
fig.patch.set_facecolor('white')
|
175 |
+
# Compose the block of text for this page
|
176 |
+
page_text = '\n'.join(wrapped_lines[i:i + lines_per_page])
|
177 |
+
# Place the text near the top-left corner. ``va='top'``
|
178 |
+
# ensures the first line starts at the specified y
|
179 |
+
fig.text(0.1, 0.95, page_text, va='top', ha='left', fontsize=10, family='monospace')
|
180 |
+
# Save and close the figure to free memory
|
181 |
+
pdf.savefig(fig)
|
182 |
+
plt.close(fig)
|
183 |
+
|
184 |
+
buffer.seek(0)
|
185 |
+
return buffer
|
186 |
+
|
187 |
+
|
188 |
+
__all__ = ['generate_llm_interview_report', 'create_pdf_report']
|
backend/templates/dashboard.html
CHANGED
@@ -13,10 +13,10 @@
|
|
13 |
|
14 |
{% block content %}
|
15 |
<section class="content-section">
|
16 |
-
<ul class="breadcrumbs">
|
17 |
<li><a href="{{ url_for('index') }}">Home</a></li>
|
18 |
<li>Dashboard</li>
|
19 |
-
</ul>
|
20 |
|
21 |
<div class="section-title">
|
22 |
<h2>Interviewed Candidates</h2>
|
@@ -44,7 +44,19 @@
|
|
44 |
<td style="padding: 0.75rem;">{{ item.application.job.role }}</td>
|
45 |
<td style="padding: 0.75rem; font-weight: 600; color: var(--secondary);">{{ item.score_label }}</td>
|
46 |
<td style="padding: 0.75rem;">
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
</td>
|
49 |
</tr>
|
50 |
{% endfor %}
|
@@ -78,4 +90,27 @@
|
|
78 |
background-color: #eef5ff;
|
79 |
}
|
80 |
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
{% endblock %}
|
|
|
13 |
|
14 |
{% block content %}
|
15 |
<section class="content-section">
|
16 |
+
<!-- <ul class="breadcrumbs">
|
17 |
<li><a href="{{ url_for('index') }}">Home</a></li>
|
18 |
<li>Dashboard</li>
|
19 |
+
</ul> -->
|
20 |
|
21 |
<div class="section-title">
|
22 |
<h2>Interviewed Candidates</h2>
|
|
|
44 |
<td style="padding: 0.75rem;">{{ item.application.job.role }}</td>
|
45 |
<td style="padding: 0.75rem; font-weight: 600; color: var(--secondary);">{{ item.score_label }}</td>
|
46 |
<td style="padding: 0.75rem;">
|
47 |
+
{#
|
48 |
+
Provide a link to download the candidate's interview report. The
|
49 |
+
``url_for`` call targets the API endpoint defined in
|
50 |
+
``backend/routes/interview_api.py``. It passes the application ID
|
51 |
+
(used as the interview identifier) so that the backend knows which
|
52 |
+
candidate to generate a report for. Styling is handled via the
|
53 |
+
``download-report-btn`` class defined below.
|
54 |
+
#}
|
55 |
+
<a href="{{ url_for('interview_api.download_report', application_id=item.application.id) }}"
|
56 |
+
class="download-report-btn"
|
57 |
+
title="Download interview report as PDF">
|
58 |
+
Download Report (PDF)
|
59 |
+
</a>
|
60 |
</td>
|
61 |
</tr>
|
62 |
{% endfor %}
|
|
|
90 |
background-color: #eef5ff;
|
91 |
}
|
92 |
</style>
|
93 |
+
<style>
|
94 |
+
/*
|
95 |
+
* Custom styling for the download report link. The default button styles
|
96 |
+
* blend into the table background, making the report action hard to see. A
|
97 |
+
* subtle border and hover effect improve visibility without altering the
|
98 |
+
* overall aesthetic of the dashboard.
|
99 |
+
*/
|
100 |
+
.download-report-btn {
|
101 |
+
display: inline-block;
|
102 |
+
padding: 0.5rem 1rem;
|
103 |
+
border: 1px solid var(--primary);
|
104 |
+
border-radius: 4px;
|
105 |
+
color: var(--primary);
|
106 |
+
background-color: #fff;
|
107 |
+
text-decoration: none;
|
108 |
+
font-size: 0.875rem;
|
109 |
+
transition: background-color 0.2s ease, color 0.2s ease;
|
110 |
+
}
|
111 |
+
.download-report-btn:hover {
|
112 |
+
background-color: var(--primary);
|
113 |
+
color: #fff;
|
114 |
+
}
|
115 |
+
</style>
|
116 |
{% endblock %}
|
backend/templates/post_job.html
CHANGED
@@ -12,11 +12,11 @@
|
|
12 |
|
13 |
{% block content %}
|
14 |
<section class="content-section">
|
15 |
-
<ul class="breadcrumbs">
|
16 |
<li><a href="{{ url_for('index') }}">Home</a></li>
|
17 |
<li><a href="{{ url_for('jobs') }}">Jobs</a></li>
|
18 |
<li>Post Job</li>
|
19 |
-
</ul>
|
20 |
|
21 |
<div class="card">
|
22 |
<div class="card-header">
|
|
|
12 |
|
13 |
{% block content %}
|
14 |
<section class="content-section">
|
15 |
+
<!-- <ul class="breadcrumbs">
|
16 |
<li><a href="{{ url_for('index') }}">Home</a></li>
|
17 |
<li><a href="{{ url_for('jobs') }}">Jobs</a></li>
|
18 |
<li>Post Job</li>
|
19 |
+
</ul> -->
|
20 |
|
21 |
<div class="card">
|
22 |
<div class="card-header">
|