hh-bot/hh_bot/services/gemini_service.py

492 lines
20 KiB
Python
Raw Normal View History

2025-06-27 09:57:34 +02:00
import json
import requests
import logging
2025-06-28 19:06:40 +02:00
import time
2025-06-27 09:57:34 +02:00
from typing import Dict, Optional, Tuple, List
2025-06-28 19:06:40 +02:00
from collections import deque
2025-06-27 09:57:34 +02:00
import traceback
from pathlib import Path
from ..config.settings import settings, AppConstants
from ..models.vacancy import Vacancy
logger = logging.getLogger(__name__)
2025-06-28 19:06:40 +02:00
class RateLimiter:
"""Ограничитель скорости запросов к API"""
def __init__(self, max_requests: int, window_seconds: int):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.request_times = deque()
def wait_if_needed(self) -> None:
"""Ожидание если превышен лимит запросов"""
2025-06-28 19:35:35 +02:00
while True:
current_time = time.time()
self._cleanup_old_requests(current_time)
if len(self.request_times) < self.max_requests:
break
oldest_request_time = self.request_times[0]
wait_time = self.window_seconds - (current_time - oldest_request_time) + 0.1
logger.info(f"⏳ Достигнут лимит {self.max_requests} запросов. "
f"Ожидание {wait_time:.1f} секунд...")
time.sleep(wait_time)
def record_request(self) -> None:
"""Записать новый запрос"""
2025-06-28 19:06:40 +02:00
current_time = time.time()
2025-06-28 19:35:35 +02:00
self._cleanup_old_requests(current_time)
self.request_times.append(current_time)
def _cleanup_old_requests(self, current_time: float) -> None:
"""Удаление старых запросов из окна"""
2025-06-28 19:06:40 +02:00
while (self.request_times and
current_time - self.request_times[0] >= self.window_seconds):
self.request_times.popleft()
def get_remaining_requests(self) -> int:
"""Количество оставшихся запросов в текущем окне"""
current_time = time.time()
2025-06-28 19:35:35 +02:00
self._cleanup_old_requests(current_time)
2025-06-28 19:06:40 +02:00
return max(0, self.max_requests - len(self.request_times))
def get_status(self) -> str:
"""Статус rate limiter для логирования"""
remaining = self.get_remaining_requests()
return (f"📊 API лимит: {remaining}/{self.max_requests} запросов осталось "
f"(окно {self.window_seconds}с)")
2025-06-27 09:57:34 +02:00
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
2025-06-28 19:06:40 +02:00
# Инициализируем rate limiter
self.rate_limiter = RateLimiter(
max_requests=settings.gemini.max_requests_per_minute,
window_seconds=settings.gemini.rate_limit_window_seconds
)
2025-06-27 09:57:34 +02:00
def generate_content(self, prompt: str) -> Optional[Dict]:
"""Генерация контента через Gemini API"""
2025-06-28 19:06:40 +02:00
self.rate_limiter.wait_if_needed()
2025-06-28 19:35:35 +02:00
self.rate_limiter.record_request()
2025-06-28 19:06:40 +02:00
2025-06-27 09:57:34 +02:00
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:
2025-06-28 19:35:35 +02:00
status_after = self.rate_limiter.get_status()
logger.info(f"Отправка запроса к Gemini API. {status_after}")
2025-06-27 09:57:34 +02:00
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}"
)
2025-06-27 09:57:34 +02:00
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)
if "match_score" in parsed_response:
score = parsed_response.get("match_score", 0)
logger.info(f"Gemini анализ завершен: {score}")
elif "cover_letter" in parsed_response:
logger.info("Gemini сгенерировал сопроводительное письмо")
else:
logger.info("Получен ответ от Gemini")
2025-06-27 09:57:34 +02:00
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.error("Ошибка анализа Gemini, используем базовую фильтрацию")
2025-06-27 09:57:34 +02:00
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
)
2025-06-27 09:57:34 +02:00
return score >= settings.gemini.match_threshold
return self.analyzer.should_apply(vacancy)
def generate_cover_letter(self, vacancy: Vacancy) -> Optional[str]:
"""Генерация сопроводительного письма для вакансии"""
if not self.is_available():
logger.warning("Gemini API недоступен, используем базовое письмо")
return self._get_default_cover_letter()
try:
resume_data = self.resume_loader.load()
vacancy_text = self._get_vacancy_full_text(vacancy)
experience_text = resume_data.get("experience", "")
about_me_text = resume_data.get("about_me", "")
skills_text = resume_data.get("skills", "")
my_profile = f"""
Опыт работы:
{experience_text}
О себе:
{about_me_text}
Навыки и технологии:
{skills_text}
"""
prompt_text = (
"Напиши короткое, человечное и честное сопроводительное письмо "
"для отклика на вакансию на русском языке. Не придумывай опыт, "
"которого нет. Используй только мой реальный опыт и навыки ниже. "
2025-06-28 18:46:30 +02:00
"Но если какого-то опыта нет, то не пиши про это."
"Пиши по делу, дружелюбно и без официоза. Не делай письмо слишком "
"длинным. Всегда заканчивай строкой «Telegram — @itqen»."
)
prompt = f"""{prompt_text}
**Верни только JSON с ключом "cover_letter", без других пояснений.**
Пример формата вывода:
{{"cover_letter": "текст письма здесь"}}
**Вот мой опыт:**
{my_profile}
**Вот текст вакансии:**
{vacancy_text}"""
logger.info("Генерация сопроводительного письма через Gemini")
response = self.api_client.generate_content(prompt)
if response and "cover_letter" in response:
cover_letter = response["cover_letter"]
logger.info("Сопроводительное письмо сгенерировано")
return cover_letter
else:
logger.error("Не удалось получить сопроводительное письмо от Gemini")
return self._get_default_cover_letter()
except Exception as e:
logger.error(f"Ошибка генерации сопроводительного письма: {e}")
return self._get_default_cover_letter()
def _get_vacancy_full_text(self, vacancy: Vacancy) -> str:
"""Получение полного текста вакансии"""
parts = [
f"Название: {vacancy.name}",
f"Компания: {vacancy.employer.name}",
]
if vacancy.snippet.requirement:
parts.append(f"Требования: {vacancy.snippet.requirement}")
if vacancy.snippet.responsibility:
parts.append(f"Обязанности: {vacancy.snippet.responsibility}")
return "\n\n".join(parts)
def _get_default_cover_letter(self) -> str:
"""Базовое сопроводительное письмо на случай ошибки"""
return """Добрый день!
Заинтересован в данной вакансии. Готов обсудить детали и возможности сотрудничества.
С уважением,
Telegram @itqen"""
2025-06-28 19:06:40 +02:00
def get_api_status(self) -> str:
"""Получение статуса API лимитов"""
if not self.api_client:
return "❌ Gemini API недоступен"
return self.api_client.rate_limiter.get_status()