Spaces:
Paused
Paused
Commit
·
e0470f3
1
Parent(s):
88df9ba
updated
Browse files
backend/services/report_generator.py
CHANGED
@@ -1,24 +1,3 @@
|
|
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 |
import json
|
@@ -341,33 +320,38 @@ def create_pdf_report(report_text: str) -> BytesIO:
|
|
341 |
|
342 |
y_pos -= 0.5
|
343 |
|
344 |
-
# Show
|
|
|
|
|
|
|
345 |
max_qa_on_page1 = min(3, len(report_data['qa_log']))
|
346 |
-
|
347 |
for i in range(max_qa_on_page1):
|
348 |
qa = report_data['qa_log'][i]
|
349 |
|
350 |
-
# Check if we have space
|
|
|
|
|
|
|
|
|
351 |
if y_pos < BOTTOM_MARGIN + 2.2:
|
352 |
break
|
353 |
|
354 |
-
# Question
|
355 |
-
question_text = f"Q{
|
356 |
for line in textwrap.wrap(question_text, width=85):
|
357 |
ax.text(LEFT_MARGIN, y_pos, line,
|
358 |
fontsize=11, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
|
359 |
y_pos -= 0.25
|
360 |
y_pos -= 0.15 # extra spacing after question block
|
361 |
|
362 |
-
|
363 |
-
# Answer
|
364 |
answer_text = qa['answer']
|
365 |
if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
|
366 |
answer_text = "Prefer not to disclose"
|
367 |
|
368 |
wrapped_answer = textwrap.fill(answer_text, width=85)
|
369 |
answer_lines = wrapped_answer.split('\n')[:2] # Max 2 lines
|
370 |
-
|
371 |
for line in answer_lines:
|
372 |
ax.text(LEFT_MARGIN + 0.3, y_pos, line,
|
373 |
fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
|
@@ -377,22 +361,27 @@ def create_pdf_report(report_text: str) -> BytesIO:
|
|
377 |
eval_color = _get_score_color(qa['score'])
|
378 |
ax.text(LEFT_MARGIN + 0.3, y_pos, f"Evaluation: {qa['score']}",
|
379 |
fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
|
380 |
-
|
381 |
y_pos -= 0.6
|
|
|
|
|
382 |
|
383 |
# Save first page
|
384 |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
|
385 |
plt.close(fig)
|
386 |
|
387 |
# === PAGE 2: REMAINING TRANSCRIPT ===
|
388 |
-
|
|
|
|
|
|
|
|
|
389 |
_create_transcript_page(
|
390 |
pdf,
|
391 |
-
report_data['qa_log'][
|
392 |
A4_WIDTH, A4_HEIGHT,
|
393 |
LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN,
|
394 |
ACCENT_COLOR, TEXT_COLOR,
|
395 |
-
start_index=
|
396 |
)
|
397 |
|
398 |
|
@@ -444,8 +433,17 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
|
|
444 |
data['skills_match']['ratio'] = float(line.split(':')[1].strip().rstrip('%'))
|
445 |
except:
|
446 |
data['skills_match']['ratio'] = 0
|
447 |
-
elif line.startswith('Score:')
|
448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
449 |
elif line.startswith('Question'):
|
450 |
if current_question:
|
451 |
data['qa_log'].append(current_question)
|
@@ -457,8 +455,6 @@ def _parse_report_text(report_text: str) -> Dict[str, Any]:
|
|
457 |
}
|
458 |
elif line.startswith('Answer:') and current_question:
|
459 |
current_question['answer'] = line.split(':', 1)[1].strip()
|
460 |
-
elif line.startswith('Score:') and current_question:
|
461 |
-
current_question['score'] = line.split(':', 1)[1].strip()
|
462 |
elif line.startswith('Feedback:') and current_question:
|
463 |
current_question['feedback'] = line.split(':', 1)[1].strip()
|
464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
|
2 |
from __future__ import annotations
|
3 |
import json
|
|
|
320 |
|
321 |
y_pos -= 0.5
|
322 |
|
323 |
+
# Show up to 3 Q&As on the first page. The number actually
|
324 |
+
# displayed depends on available space. We track how many
|
325 |
+
# questions we render so the remainder can be displayed on
|
326 |
+
# subsequent pages without skipping any entries.
|
327 |
max_qa_on_page1 = min(3, len(report_data['qa_log']))
|
328 |
+
qa_count_on_page1 = 0
|
329 |
for i in range(max_qa_on_page1):
|
330 |
qa = report_data['qa_log'][i]
|
331 |
|
332 |
+
# Check if we have space for the next Q&A. If not, break
|
333 |
+
# early. The 2.2 constant accounts for the approximate
|
334 |
+
# vertical space needed for a question, answer, evaluation
|
335 |
+
# and some spacing. If insufficient space remains, we
|
336 |
+
# stop adding to this page.
|
337 |
if y_pos < BOTTOM_MARGIN + 2.2:
|
338 |
break
|
339 |
|
340 |
+
# Question number starts at 1 on the first page
|
341 |
+
question_text = f"Q{qa_count_on_page1 + 1}: {qa['question']}"
|
342 |
for line in textwrap.wrap(question_text, width=85):
|
343 |
ax.text(LEFT_MARGIN, y_pos, line,
|
344 |
fontsize=11, fontweight='bold', color=ACCENT_COLOR, fontfamily='sans-serif')
|
345 |
y_pos -= 0.25
|
346 |
y_pos -= 0.15 # extra spacing after question block
|
347 |
|
348 |
+
# Answer. Mask salary disclosure if applicable.
|
|
|
349 |
answer_text = qa['answer']
|
350 |
if "salary" in qa['question'].lower() and (answer_text == "0$" or answer_text == "0" or answer_text == "$0"):
|
351 |
answer_text = "Prefer not to disclose"
|
352 |
|
353 |
wrapped_answer = textwrap.fill(answer_text, width=85)
|
354 |
answer_lines = wrapped_answer.split('\n')[:2] # Max 2 lines
|
|
|
355 |
for line in answer_lines:
|
356 |
ax.text(LEFT_MARGIN + 0.3, y_pos, line,
|
357 |
fontsize=10, color=TEXT_COLOR, fontfamily='sans-serif')
|
|
|
361 |
eval_color = _get_score_color(qa['score'])
|
362 |
ax.text(LEFT_MARGIN + 0.3, y_pos, f"Evaluation: {qa['score']}",
|
363 |
fontsize=10, fontweight='bold', color=eval_color, fontfamily='sans-serif')
|
|
|
364 |
y_pos -= 0.6
|
365 |
+
|
366 |
+
qa_count_on_page1 += 1
|
367 |
|
368 |
# Save first page
|
369 |
pdf.savefig(fig, bbox_inches='tight', pad_inches=0)
|
370 |
plt.close(fig)
|
371 |
|
372 |
# === PAGE 2: REMAINING TRANSCRIPT ===
|
373 |
+
# Render the remainder of the Q&A log on additional pages. Use
|
374 |
+
# qa_count_on_page1 (actual number shown on the first page) rather
|
375 |
+
# than the theoretical max_qa_on_page1 so that no entries are
|
376 |
+
# inadvertently skipped when the first page runs out of space.
|
377 |
+
if report_data['qa_log'] and len(report_data['qa_log']) > qa_count_on_page1:
|
378 |
_create_transcript_page(
|
379 |
pdf,
|
380 |
+
report_data['qa_log'][qa_count_on_page1:], # Continue from the next unanswered question
|
381 |
A4_WIDTH, A4_HEIGHT,
|
382 |
LEFT_MARGIN, RIGHT_MARGIN, TOP_MARGIN, BOTTOM_MARGIN,
|
383 |
ACCENT_COLOR, TEXT_COLOR,
|
384 |
+
start_index=qa_count_on_page1 + 1 # Correct numbering
|
385 |
)
|
386 |
|
387 |
|
|
|
433 |
data['skills_match']['ratio'] = float(line.split(':')[1].strip().rstrip('%'))
|
434 |
except:
|
435 |
data['skills_match']['ratio'] = 0
|
436 |
+
elif line.startswith('Score:'):
|
437 |
+
# Distinguish between the overall skills match score and per‑question scores.
|
438 |
+
# If no question has been started yet (i.e. current_question is None),
|
439 |
+
# interpret this Score line as the skills match score. Otherwise it
|
440 |
+
# belongs to the most recent question.
|
441 |
+
score_value = line.split(':', 1)[1].strip()
|
442 |
+
if current_question is None:
|
443 |
+
data['skills_match']['score'] = score_value
|
444 |
+
else:
|
445 |
+
current_question['score'] = score_value
|
446 |
+
continue
|
447 |
elif line.startswith('Question'):
|
448 |
if current_question:
|
449 |
data['qa_log'].append(current_question)
|
|
|
455 |
}
|
456 |
elif line.startswith('Answer:') and current_question:
|
457 |
current_question['answer'] = line.split(':', 1)[1].strip()
|
|
|
|
|
458 |
elif line.startswith('Feedback:') and current_question:
|
459 |
current_question['feedback'] = line.split(':', 1)[1].strip()
|
460 |
|