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