hh-bot/hh_bot/services/gemini_service.py

333 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
🤖 Сервис для работы с Gemini AI
"""
import json
import requests
import logging
from typing import Dict, Optional, Tuple, List
import traceback
from pathlib import Path
from ..config.settings import settings, AppConstants
from ..models.vacancy import Vacancy
logger = logging.getLogger(__name__)
class GeminiApiClient:
"""Клиент для работы с Gemini API"""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = AppConstants.GEMINI_BASE_URL
self.model = AppConstants.GEMINI_MODEL
def generate_content(self, prompt: str) -> Optional[Dict]:
"""Генерация контента через Gemini API"""
url = f"{self.base_url}/models/{self.model}:generateContent"
headers = {"Content-Type": "application/json"}
params = {"key": self.api_key}
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": AppConstants.GEMINI_TEMPERATURE,
"maxOutputTokens": AppConstants.GEMINI_MAX_OUTPUT_TOKENS,
},
}
try:
logger.info("Отправка запроса к Gemini API")
response = requests.post(
url,
headers=headers,
params=params,
json=payload,
timeout=AppConstants.DEFAULT_TIMEOUT,
)
if response.status_code != 200:
logger.error(f"Ошибка API Gemini: {response.status_code}, {response.text}")
return None
result = response.json()
return self._parse_response(result)
except requests.RequestException as e:
logger.error(f"Ошибка сети при запросе к Gemini: {e}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка Gemini API: {e}")
return None
def _parse_response(self, result: Dict) -> Optional[Dict]:
"""Парсинг ответа от Gemini API"""
try:
if "candidates" not in result or not result["candidates"]:
logger.warning("Пустой ответ от Gemini")
return None
content = result["candidates"][0]["content"]["parts"][0]["text"]
return self._extract_json_from_text(content)
except (KeyError, IndexError) as e:
logger.error(f"Ошибка структуры ответа Gemini: {e}")
return None
def _extract_json_from_text(self, content: str) -> Optional[Dict]:
"""Извлечение JSON из текстового ответа"""
try:
json_start = content.find("{")
json_end = content.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
json_str = content[json_start:json_end]
parsed_response = json.loads(json_str)
score = parsed_response.get("match_score", 0)
logger.info(f"Gemini анализ завершен: {score}")
return parsed_response
else:
logger.error("JSON не найден в ответе Gemini")
return None
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON от Gemini: {e}")
logger.debug(f"Контент: {content}")
return None
class ResumeDataLoader:
"""Загрузчик данных резюме из файлов"""
def __init__(self):
self._cache: Optional[Dict[str, str]] = None
def load(self) -> Dict[str, str]:
"""Загрузка данных резюме с кэшированием"""
if self._cache is not None:
return self._cache
try:
resume_data = self._load_from_files()
self._cache = resume_data
return resume_data
except Exception as e:
logger.error(f"Ошибка загрузки файлов резюме: {e}")
return self._get_default_resume_data()
def _load_from_files(self) -> Dict[str, str]:
"""Загрузка из файлов"""
resume_data = {}
file_mappings = {
"experience": settings.resume.experience_file,
"about_me": settings.resume.about_me_file,
"skills": settings.resume.skills_file,
}
for key, relative_path in file_mappings.items():
file_path = self._get_file_path(relative_path)
if file_path.exists():
resume_data[key] = file_path.read_text(encoding="utf-8")
logger.info(f"Загружен {key} из {file_path}")
else:
resume_data[key] = self._get_default_value(key)
logger.warning(f"Файл {file_path} не найден, используем заглушку")
return resume_data
def _get_file_path(self, relative_path: str) -> Path:
"""Получение абсолютного пути к файлу"""
if Path(relative_path).is_absolute():
return Path(relative_path)
return Path.cwd() / relative_path
def _get_default_value(self, key: str) -> str:
"""Получение значений по умолчанию"""
defaults = {
"experience": "Без опыта работы. Начинающий разработчик.",
"about_me": "Начинающий Python разработчик, изучающий программирование.",
"skills": "Python, SQL, Git, основы веб-разработки",
}
return defaults.get(key, "Не указано")
def _get_default_resume_data(self) -> Dict[str, str]:
"""Полный набор данных по умолчанию"""
return {
"experience": self._get_default_value("experience"),
"about_me": self._get_default_value("about_me"),
"skills": self._get_default_value("skills"),
}
class VacancyAnalyzer:
"""Анализатор соответствия вакансий"""
def __init__(self, api_client: GeminiApiClient, resume_loader: ResumeDataLoader):
self.api_client = api_client
self.resume_loader = resume_loader
self.match_threshold = settings.gemini.match_threshold
def analyze(self, vacancy: Vacancy) -> Tuple[float, List[str]]:
"""Анализ соответствия вакансии резюме"""
try:
resume_data = self.resume_loader.load()
prompt = self._create_prompt(vacancy, resume_data)
response = self.api_client.generate_content(prompt)
if response and "match_score" in response:
score = float(response["match_score"])
reasons = response.get("match_reasons", ["AI анализ выполнен"])
return self._validate_score(score), reasons
else:
logger.warning("Ошибка анализа Gemini, используем базовую фильтрацию")
return self._basic_analysis(vacancy)
except Exception as e:
logger.error(f"Ошибка в анализе Gemini: {e}")
logger.debug(f"Traceback: {traceback.format_exc()}")
return self._basic_analysis(vacancy)
def _create_prompt(self, vacancy: Vacancy, resume_data: Dict[str, str]) -> str:
"""Создание промпта для анализа соответствия"""
prompt = f"""
Проанализируй соответствие между резюме кандидата и вакансией.
Верни ТОЛЬКО JSON с такой структурой:
{{
"match_score": 0.85,
"match_reasons": ["причина1", "причина2"],
"recommendation": "стоит откликаться"
}}
РЕЗЮМЕ КАНДИДАТА:
Опыт работы: {resume_data.get('experience', 'Не указан')}
О себе: {resume_data.get('about_me', 'Не указано')}
Навыки: {resume_data.get('skills', 'Не указаны')}
ВАКАНСИЯ:
Название: {vacancy.name}
Компания: {vacancy.employer.name}
Требования: {vacancy.snippet.requirement or 'Не указаны'}
Обязанности: {vacancy.snippet.responsibility or 'Не указаны'}
Опыт: {vacancy.experience.name}
Оцени соответствие от {AppConstants.MIN_AI_SCORE} до {AppConstants.MAX_AI_SCORE}, где:
- 0.0-0.3: Не подходит
- 0.4-0.6: Частично подходит
- 0.7-0.9: Хорошо подходит
- 0.9-1.0: Отлично подходит
В match_reasons укажи конкретные причины оценки.
В recommendation: "стоит откликаться", "стоит подумать" или "не стоит откликаться".
"""
return prompt.strip()
def _validate_score(self, score: float) -> float:
"""Валидация оценки AI"""
return max(AppConstants.MIN_AI_SCORE, min(AppConstants.MAX_AI_SCORE, score))
def _basic_analysis(self, vacancy: Vacancy) -> Tuple[float, List[str]]:
"""Базовый анализ без AI (фолбэк)"""
score = 0.0
reasons = []
try:
if vacancy.has_python():
score += 0.4
reasons.append("Содержит Python в требованиях")
else:
reasons.append("Не содержит Python в требованиях")
return 0.1, reasons
if vacancy.is_junior_level():
score += 0.3
reasons.append("Подходящий уровень (junior)")
elif vacancy.experience.id in ["noExperience", "between1And3"]:
score += 0.2
reasons.append("Приемлемый опыт работы")
if not vacancy.has_test:
score += 0.1
reasons.append("Без обязательного тестирования")
else:
reasons.append("Есть обязательное тестирование")
if not vacancy.archived:
score += 0.1
reasons.append("Актуальная вакансия")
return min(score, 1.0), reasons
except Exception as e:
logger.error(f"Ошибка базового анализа: {e}")
return 0.0, ["Ошибка анализа"]
def should_apply(self, vacancy: Vacancy) -> bool:
"""Принятие решения о подаче заявки"""
try:
score, reasons = self.analyze(vacancy)
vacancy.ai_match_score = score
vacancy.ai_match_reasons = reasons
should_apply = score >= self.match_threshold
if should_apply:
logger.info(f"Рекомендуется откликаться (score: {score:.2f})")
else:
logger.info(f"Не рекомендуется откликаться (score: {score:.2f})")
return should_apply
except Exception as e:
logger.error(f"Ошибка в should_apply: {e}")
return False
class GeminiAIService:
"""Главный сервис для анализа вакансий с помощью Gemini AI"""
def __init__(self):
self.api_key = settings.gemini.api_key
if self.api_key:
self.api_client = GeminiApiClient(self.api_key)
else:
self.api_client = None
self.resume_loader = ResumeDataLoader()
if self.api_client:
self.analyzer = VacancyAnalyzer(self.api_client, self.resume_loader)
else:
self.analyzer = None
def is_available(self) -> bool:
"""Проверка доступности сервиса"""
return bool(self.api_key)
def load_resume_data(self) -> Dict[str, str]:
"""Загрузка данных резюме из файлов"""
return self.resume_loader.load()
def analyze_vacancy_match(self, vacancy: Vacancy) -> Tuple[float, List[str]]:
"""Анализ соответствия вакансии резюме"""
if not self.is_available() or not self.analyzer:
logger.warning("Gemini API недоступен, используем базовую фильтрацию")
return VacancyAnalyzer(None, self.resume_loader)._basic_analysis(vacancy)
return self.analyzer.analyze(vacancy)
def should_apply(self, vacancy: Vacancy) -> bool:
"""Принятие решения о подаче заявки"""
if not self.is_available() or not self.analyzer:
score, _ = VacancyAnalyzer(None, self.resume_loader)._basic_analysis(vacancy)
return score >= settings.gemini.match_threshold
return self.analyzer.should_apply(vacancy)