499 lines
17 KiB
Python
499 lines
17 KiB
Python
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||
from fastapi.responses import RedirectResponse
|
||
from pydantic import BaseModel
|
||
|
||
from app.core.database import get_session
|
||
from app.repositories.resume_repository import ResumeRepository
|
||
from app.services.pdf_report_service import pdf_report_service
|
||
from celery_worker.interview_analysis_task import (
|
||
analyze_multiple_candidates,
|
||
generate_interview_report,
|
||
)
|
||
|
||
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||
|
||
|
||
class AnalysisResponse(BaseModel):
|
||
"""Ответ запуска задачи анализа"""
|
||
|
||
message: str
|
||
resume_id: int
|
||
task_id: str
|
||
|
||
|
||
class BulkAnalysisRequest(BaseModel):
|
||
"""Запрос массового анализа"""
|
||
|
||
resume_ids: list[int]
|
||
|
||
|
||
class BulkAnalysisResponse(BaseModel):
|
||
"""Ответ массового анализа"""
|
||
|
||
message: str
|
||
resume_count: int
|
||
task_id: str
|
||
|
||
|
||
class CandidateRanking(BaseModel):
|
||
"""Рейтинг кандидата"""
|
||
|
||
resume_id: int
|
||
candidate_name: str
|
||
overall_score: int
|
||
recommendation: str
|
||
position: str
|
||
|
||
|
||
class PDFGenerationResponse(BaseModel):
|
||
"""Ответ генерации PDF отчета"""
|
||
|
||
message: str
|
||
resume_id: int
|
||
candidate_name: str
|
||
pdf_url: str | None = None
|
||
status: str # "generated", "exists", "failed"
|
||
|
||
|
||
@router.post("/interview-report/{resume_id}", response_model=AnalysisResponse)
|
||
async def start_interview_analysis(
|
||
resume_id: int,
|
||
background_tasks: BackgroundTasks,
|
||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||
):
|
||
"""
|
||
Запускает анализ интервью для конкретного кандидата
|
||
|
||
Анализирует:
|
||
- Соответствие резюме вакансии
|
||
- Качество ответов в диалоге интервью
|
||
- Технические навыки и опыт
|
||
- Коммуникативные способности
|
||
- Общую рекомендацию и рейтинг
|
||
"""
|
||
|
||
# Проверяем, существует ли резюме
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
|
||
if not resume:
|
||
raise HTTPException(status_code=404, detail="Resume not found")
|
||
|
||
# Запускаем задачу анализа
|
||
task = generate_interview_report.delay(resume_id)
|
||
|
||
return AnalysisResponse(
|
||
message="Interview analysis started", resume_id=resume_id, task_id=task.id
|
||
)
|
||
|
||
|
||
@router.post("/bulk-analysis", response_model=BulkAnalysisResponse)
|
||
async def start_bulk_analysis(
|
||
request: BulkAnalysisRequest,
|
||
background_tasks: BackgroundTasks,
|
||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||
):
|
||
"""
|
||
Запускает массовый анализ нескольких кандидатов
|
||
|
||
Возвращает ранжированный список кандидатов по общему баллу
|
||
Полезно для сравнения кандидатов на одну позицию
|
||
"""
|
||
|
||
# Проверяем, что все резюме существуют
|
||
existing_resumes = []
|
||
|
||
for resume_id in request.resume_ids:
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
if resume:
|
||
existing_resumes.append(resume_id)
|
||
|
||
if not existing_resumes:
|
||
raise HTTPException(status_code=404, detail="No valid resumes found")
|
||
|
||
# Запускаем задачу массового анализа
|
||
task = analyze_multiple_candidates.delay(existing_resumes)
|
||
|
||
return BulkAnalysisResponse(
|
||
message="Bulk analysis started",
|
||
resume_count=len(existing_resumes),
|
||
task_id=task.id,
|
||
)
|
||
|
||
|
||
@router.get("/ranking/{vacancy_id}")
|
||
async def get_candidates_ranking(
|
||
vacancy_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
|
||
):
|
||
"""
|
||
Получить ранжированный список кандидатов для вакансии
|
||
|
||
Сортирует кандидатов по результатам анализа интервью
|
||
Показывает только тех, кто прошел интервью
|
||
"""
|
||
|
||
# Получаем все резюме для вакансии со статусом "interviewed"
|
||
resumes = await resume_repo.get_by_vacancy_id(vacancy_id)
|
||
interviewed_resumes = [r for r in resumes if r.status in ["interviewed"]]
|
||
|
||
if not interviewed_resumes:
|
||
return {
|
||
"vacancy_id": vacancy_id,
|
||
"candidates": [],
|
||
"message": "No interviewed candidates found",
|
||
}
|
||
|
||
# Запускаем массовый анализ если еще не было
|
||
resume_ids = [r.id for r in interviewed_resumes]
|
||
task = analyze_multiple_candidates.delay(resume_ids)
|
||
|
||
# В реальности здесь нужно дождаться выполнения или получить из кэша
|
||
# Пока возвращаем информацию о запущенной задаче
|
||
return {
|
||
"vacancy_id": vacancy_id,
|
||
"task_id": task.id,
|
||
"message": f"Analysis started for {len(resume_ids)} candidates",
|
||
"resume_ids": resume_ids,
|
||
}
|
||
|
||
|
||
@router.get("/report/{resume_id}")
|
||
async def get_interview_report(
|
||
resume_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
|
||
):
|
||
"""
|
||
Получить готовый отчет анализа интервью
|
||
|
||
Если отчет еще не готов - запускает анализ
|
||
"""
|
||
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
|
||
if not resume:
|
||
raise HTTPException(status_code=404, detail="Resume not found")
|
||
|
||
# Проверяем, есть ли уже готовый отчет в notes
|
||
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
|
||
return {
|
||
"resume_id": resume_id,
|
||
"candidate_name": resume.applicant_name,
|
||
"status": "completed",
|
||
"report_summary": resume.notes,
|
||
"message": "Report available",
|
||
}
|
||
|
||
# Если отчета нет - запускаем анализ
|
||
task = generate_interview_report.delay(resume_id)
|
||
|
||
return {
|
||
"resume_id": resume_id,
|
||
"candidate_name": resume.applicant_name,
|
||
"status": "in_progress",
|
||
"task_id": task.id,
|
||
"message": "Analysis started, check back later",
|
||
}
|
||
|
||
|
||
@router.get("/statistics/{vacancy_id}")
|
||
async def get_analysis_statistics(
|
||
vacancy_id: int, resume_repo: ResumeRepository = Depends(ResumeRepository)
|
||
):
|
||
"""
|
||
Получить статистику анализа кандидатов по вакансии
|
||
"""
|
||
|
||
resumes = await resume_repo.get_by_vacancy_id(vacancy_id)
|
||
|
||
total_candidates = len(resumes)
|
||
interviewed = len([r for r in resumes if r.status == "interviewed"])
|
||
with_reports = len(
|
||
[r for r in resumes if r.notes and "ОЦЕНКА КАНДИДАТА" in r.notes]
|
||
)
|
||
|
||
# Подсчитываем рекомендации из notes (упрощенно)
|
||
recommendations = {
|
||
"strongly_recommend": 0,
|
||
"recommend": 0,
|
||
"consider": 0,
|
||
"reject": 0,
|
||
}
|
||
|
||
for resume in resumes:
|
||
if resume.notes and "ОЦЕНКА КАНДИДАТА" in resume.notes:
|
||
notes = resume.notes.lower()
|
||
if "strongly_recommend" in notes:
|
||
recommendations["strongly_recommend"] += 1
|
||
elif "recommend" in notes and "strongly_recommend" not in notes:
|
||
recommendations["recommend"] += 1
|
||
elif "consider" in notes:
|
||
recommendations["consider"] += 1
|
||
elif "reject" in notes:
|
||
recommendations["reject"] += 1
|
||
|
||
return {
|
||
"vacancy_id": vacancy_id,
|
||
"statistics": {
|
||
"total_candidates": total_candidates,
|
||
"interviewed_candidates": interviewed,
|
||
"analyzed_candidates": with_reports,
|
||
"recommendations": recommendations,
|
||
"analysis_completion": round((with_reports / max(interviewed, 1)) * 100, 1)
|
||
if interviewed > 0
|
||
else 0,
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/pdf-report/{resume_id}")
|
||
async def get_pdf_report(
|
||
resume_id: int,
|
||
session=Depends(get_session),
|
||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||
):
|
||
"""
|
||
Получить PDF отчет по интервью
|
||
|
||
Если отчет готов - перенаправляет на S3 URL
|
||
Если отчета нет - возвращает информацию о статусе
|
||
"""
|
||
from sqlmodel import select
|
||
|
||
from app.models.interview import InterviewSession
|
||
from app.models.interview_report import InterviewReport
|
||
|
||
# Проверяем, существует ли резюме
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
if not resume:
|
||
raise HTTPException(status_code=404, detail="Resume not found")
|
||
|
||
# Ищем сессию интервью и отчет
|
||
statement = (
|
||
select(InterviewReport, InterviewSession)
|
||
.join(
|
||
InterviewSession,
|
||
InterviewReport.interview_session_id == InterviewSession.id,
|
||
)
|
||
.where(InterviewSession.resume_id == resume_id)
|
||
)
|
||
|
||
result = await session.execute(statement)
|
||
report_session = result.first()
|
||
|
||
if not report_session:
|
||
# Если отчета нет - возможно, нужно запустить анализ
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Interview report not found. Run analysis first using POST /analysis/interview-report/{resume_id}",
|
||
)
|
||
|
||
report, interview_session = report_session
|
||
|
||
if not report.pdf_report_url:
|
||
# PDF еще не сгенерирован
|
||
return {
|
||
"status": "pdf_not_ready",
|
||
"message": "PDF report is being generated or failed to generate",
|
||
"report_id": report.id,
|
||
"candidate_name": resume.applicant_name,
|
||
}
|
||
|
||
# Перенаправляем на S3 URL
|
||
return RedirectResponse(url=report.pdf_report_url, status_code=302)
|
||
|
||
|
||
@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse)
|
||
async def generate_pdf_report(
|
||
resume_id: int,
|
||
session=Depends(get_session),
|
||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||
):
|
||
"""
|
||
Генерирует PDF отчет по интервью
|
||
|
||
Проверяет наличие отчета в базе данных и генерирует PDF файл.
|
||
Если PDF уже существует, возвращает существующий URL.
|
||
"""
|
||
from sqlmodel import select
|
||
|
||
from app.models.interview import InterviewSession
|
||
from app.models.interview_report import InterviewReport
|
||
|
||
# Проверяем, существует ли резюме
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
if not resume:
|
||
raise HTTPException(status_code=404, detail="Resume not found")
|
||
|
||
# Ищем отчет интервью
|
||
statement = (
|
||
select(InterviewReport, InterviewSession)
|
||
.join(
|
||
InterviewSession,
|
||
InterviewReport.interview_session_id == InterviewSession.id,
|
||
)
|
||
.where(InterviewSession.resume_id == resume_id)
|
||
)
|
||
|
||
result = await session.execute(statement)
|
||
report_session = result.first()
|
||
|
||
if not report_session:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="Interview report not found. Run analysis first using POST /analysis/interview-report/{resume_id}",
|
||
)
|
||
|
||
report, interview_session = report_session
|
||
|
||
# Если PDF уже существует, возвращаем его
|
||
if report.pdf_report_url:
|
||
return PDFGenerationResponse(
|
||
message="PDF report already exists",
|
||
resume_id=resume_id,
|
||
candidate_name=resume.applicant_name,
|
||
pdf_url=report.pdf_report_url,
|
||
status="exists",
|
||
)
|
||
|
||
# Генерируем PDF отчет
|
||
try:
|
||
# Получаем позицию из связанной вакансии
|
||
from app.models.vacancy import Vacancy
|
||
|
||
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
||
vacancy_result = await session.execute(vacancy_stmt)
|
||
vacancy = vacancy_result.scalar_one_or_none()
|
||
|
||
position = vacancy.title if vacancy else "Позиция не указана"
|
||
|
||
# Генерируем и загружаем PDF
|
||
pdf_url = await pdf_report_service.generate_and_upload_pdf(
|
||
report, resume.applicant_name, position
|
||
)
|
||
|
||
if not pdf_url:
|
||
raise HTTPException(
|
||
status_code=500, detail="Failed to generate or upload PDF report"
|
||
)
|
||
|
||
# Обновляем отчет в БД
|
||
from sqlmodel import update
|
||
|
||
stmt = (
|
||
update(InterviewReport)
|
||
.where(InterviewReport.id == report.id)
|
||
.values(pdf_report_url=pdf_url)
|
||
)
|
||
await session.execute(stmt)
|
||
await session.commit()
|
||
|
||
return PDFGenerationResponse(
|
||
message="PDF report generated successfully",
|
||
resume_id=resume_id,
|
||
candidate_name=resume.applicant_name,
|
||
pdf_url=pdf_url,
|
||
status="generated",
|
||
)
|
||
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500, detail=f"Error generating PDF report: {str(e)}"
|
||
)
|
||
|
||
|
||
@router.get("/report-data/{resume_id}")
|
||
async def get_report_data(
|
||
resume_id: int,
|
||
session=Depends(get_session),
|
||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||
):
|
||
"""
|
||
Получить данные отчета в JSON формате (без PDF)
|
||
"""
|
||
from sqlmodel import select
|
||
|
||
from app.models.interview import InterviewSession
|
||
from app.models.interview_report import InterviewReport
|
||
|
||
# Проверяем, существует ли резюме
|
||
resume = await resume_repo.get_by_id(resume_id)
|
||
if not resume:
|
||
raise HTTPException(status_code=404, detail="Resume not found")
|
||
|
||
# Ищем отчет
|
||
statement = (
|
||
select(InterviewReport, InterviewSession)
|
||
.join(
|
||
InterviewSession,
|
||
InterviewReport.interview_session_id == InterviewSession.id,
|
||
)
|
||
.where(InterviewSession.resume_id == resume_id)
|
||
)
|
||
|
||
result = await session.execute(statement)
|
||
report_session = result.first()
|
||
|
||
if not report_session:
|
||
raise HTTPException(status_code=404, detail="Interview report not found")
|
||
|
||
report, interview_session = report_session
|
||
|
||
# Получаем позицию из связанной вакансии
|
||
from app.models.vacancy import Vacancy
|
||
|
||
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
||
vacancy_result = await session.execute(vacancy_stmt)
|
||
vacancy = vacancy_result.scalar_one_or_none()
|
||
|
||
position = vacancy.title if vacancy else "Позиция не указана"
|
||
|
||
return {
|
||
"report_id": report.id,
|
||
"candidate_name": resume.applicant_name,
|
||
"position": position,
|
||
"interview_date": report.created_at.isoformat(),
|
||
"overall_score": report.overall_score,
|
||
"recommendation": report.recommendation.value,
|
||
"scores": {
|
||
"technical_skills": {
|
||
"score": report.technical_skills_score,
|
||
"justification": report.technical_skills_justification,
|
||
"concerns": report.technical_skills_concerns,
|
||
},
|
||
"experience_relevance": {
|
||
"score": report.experience_relevance_score,
|
||
"justification": report.experience_relevance_justification,
|
||
"concerns": report.experience_relevance_concerns,
|
||
},
|
||
"communication": {
|
||
"score": report.communication_score,
|
||
"justification": report.communication_justification,
|
||
"concerns": report.communication_concerns,
|
||
},
|
||
"problem_solving": {
|
||
"score": report.problem_solving_score,
|
||
"justification": report.problem_solving_justification,
|
||
"concerns": report.problem_solving_concerns,
|
||
},
|
||
"cultural_fit": {
|
||
"score": report.cultural_fit_score,
|
||
"justification": report.cultural_fit_justification,
|
||
"concerns": report.cultural_fit_concerns,
|
||
},
|
||
},
|
||
"strengths": report.strengths,
|
||
"weaknesses": report.weaknesses,
|
||
"red_flags": report.red_flags,
|
||
"next_steps": report.next_steps,
|
||
"metrics": {
|
||
"interview_duration_minutes": report.interview_duration_minutes,
|
||
"dialogue_messages_count": report.dialogue_messages_count,
|
||
"questions_quality_score": report.questions_quality_score,
|
||
},
|
||
"pdf_available": bool(report.pdf_report_url),
|
||
"pdf_url": report.pdf_report_url,
|
||
"analysis_metadata": {
|
||
"method": report.analysis_method,
|
||
"model_used": report.llm_model_used,
|
||
"analysis_duration": report.analysis_duration_seconds,
|
||
},
|
||
}
|