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 PDFReportService 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_report_service: PDFReportService = Depends(PDFReportService), ): """ Генерирует 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, }, }