husseinelsaadi commited on
Commit
b5d3943
·
1 Parent(s): ad080c9

report added

Browse files
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
- <button class="btn btn-outline" disabled style="cursor: not-allowed;">Download Report (PDF)</button>
 
 
 
 
 
 
 
 
 
 
 
 
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">