ai-hackaton-backend/app/services/vacancy_parser_service.py
2025-09-09 20:26:14 +05:00

309 lines
12 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.

import io
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class VacancyParserService:
"""Сервис для парсинга вакансий из файлов различных форматов"""
def __init__(self):
self.supported_formats = [".pdf", ".docx", ".rtf", ".txt"]
def extract_text_from_file(self, file_content: bytes, filename: str) -> str:
"""
Извлекает текст из файла в зависимости от его формата
Args:
file_content: Содержимое файла в байтах
filename: Имя файла для определения формата
Returns:
str: Извлеченный текст
"""
file_extension = Path(filename).suffix.lower()
try:
if file_extension == ".pdf":
return self._extract_from_pdf(file_content)
elif file_extension == ".docx":
return self._extract_from_docx(file_content)
elif file_extension == ".rtf":
return self._extract_from_rtf(file_content)
elif file_extension == ".txt":
return self._extract_from_txt(file_content)
else:
raise ValueError(f"Неподдерживаемый формат файла: {file_extension}")
except Exception as e:
logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}")
raise
def _extract_from_pdf(self, file_content: bytes) -> str:
"""Извлекает текст из PDF файла"""
try:
import PyPDF2
pdf_file = io.BytesIO(file_content)
pdf_reader = PyPDF2.PdfReader(pdf_file)
text = ""
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
return text.strip()
except ImportError:
# Fallback to pdfplumber if PyPDF2 doesn't work well
try:
import pdfplumber
pdf_file = io.BytesIO(file_content)
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить PyPDF2 или pdfplumber: pip install PyPDF2 pdfplumber"
)
def _extract_from_docx(self, file_content: bytes) -> str:
"""Извлекает текст из DOCX файла"""
try:
import docx
doc_file = io.BytesIO(file_content)
doc = docx.Document(doc_file)
text = ""
for paragraph in doc.paragraphs:
text += paragraph.text + "\n"
# Также извлекаем текст из таблиц
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text += cell.text + "\t"
text += "\n"
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить python-docx: pip install python-docx"
)
def _extract_from_rtf(self, file_content: bytes) -> str:
"""Извлекает текст из RTF файла"""
try:
from striprtf.striprtf import rtf_to_text
rtf_content = file_content.decode("utf-8", errors="ignore")
text = rtf_to_text(rtf_content)
return text.strip()
except ImportError:
raise ImportError("Требуется установить striprtf: pip install striprtf")
except Exception:
# Альтернативный метод через pyth
try:
from pyth.plugins.plaintext.writer import PlaintextWriter
from pyth.plugins.rtf15.reader import Rtf15Reader
doc = Rtf15Reader.read(io.BytesIO(file_content))
text = PlaintextWriter.write(doc).getvalue()
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить striprtf или pyth: pip install striprtf pyth"
)
def _extract_from_txt(self, file_content: bytes) -> str:
"""Извлекает текст из TXT файла"""
try:
# Пробуем различные кодировки
encodings = ["utf-8", "windows-1251", "cp1252", "iso-8859-1"]
for encoding in encodings:
try:
text = file_content.decode(encoding)
return text.strip()
except UnicodeDecodeError:
continue
# Если все кодировки не подошли, используем errors='ignore'
text = file_content.decode("utf-8", errors="ignore")
return text.strip()
except Exception as e:
logger.error(f"Ошибка при чтении txt файла: {str(e)}")
raise
async def parse_vacancy_with_ai(self, raw_text: str) -> dict[str, Any]:
"""
Парсит текст вакансии с помощью AI для извлечения структурированной информации
Args:
raw_text: Сырой текст вакансии
Returns:
Dict с полями для модели Vacancy
"""
from rag.settings import settings
if not settings.openai_api_key:
raise ValueError("OpenAI API ключ не настроен")
try:
import openai
openai.api_key = settings.openai_api_key
parsing_prompt = f"""
Проанализируй текст вакансии и извлеки из него структурированную информацию.
ТЕКСТ ВАКАНСИИ:
{raw_text}
ЗАДАЧА:
Извлеки следующие поля для вакансии:
1. title - название позиции (строка)
2. description - описание вакансии (полное описание обязанностей, требований)
3. key_skills - ключевые навыки через запятую (строка)
4. employment_type - тип занятости: "full", "part", "project", "volunteer", "probation"
5. experience - опыт работы: "noExperience", "between1And3", "between3And6", "moreThan6"
6. schedule - график работы: "fullDay", "shift", "flexible", "remote", "flyInFlyOut"
7. salary_from - зарплата от (число или null)
8. salary_to - зарплата до (число или null)
9. salary_currency - валюта (строка, по умолчанию "RUR")
10. company_name - название компании (строка)
11. company_description - описание компании (строка или null)
12. area_name - город/регион (строка)
13. address - адрес (строка или null)
14. professional_roles - профессиональные роли (строка или null)
15. contacts_name - контактное лицо (строка или null)
16. contacts_email - email для связи (строка или null)
17. contacts_phone - телефон для связи (строка или null)
ПРАВИЛА:
- Если информация не найдена, ставь null для необязательных полей
- Для обязательных полей используй разумные значения по умолчанию
- Зарплату указывай в рублях, конвертируй если нужно
- Опыт определяй по годам: 0-1 = noExperience, 1-3 = between1And3, 3-6 = between3And6, 6+ = moreThan6
- График работы определяй по описанию: офис = fullDay, удаленка = remote, гибкий = flexible
ФОРМАТИРОВАНИЕ ТЕКСТА:
- Если в тексте есть списки (обязанности, требования, навыки), форматируй их с переносами строк
- Используй символ \n для переноса строки между пунктами списка
- Пример: "Обязанности:\nВедение переговоров\nПодготовка документов\nОбучение персонала"
- Для ключевых навыков разделяй запятыми, но если их много - используй переносы строк
- В описании компании тоже используй переносы для лучшей читаемости
ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с указанными полями:
"""
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": parsing_prompt}],
response_format={"type": "json_object"},
)
parsed_data = json.loads(response.choices[0].message.content)
# Валидируем и обрабатываем данные
return self._validate_parsed_data(parsed_data)
except Exception as e:
logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}")
raise
def _validate_parsed_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Валидирует и очищает спарсенные данные"""
from app.models.vacancy import EmploymentType, Experience, Schedule
# Обязательные поля с дефолтными значениями
validated_data = {
"title": data.get("title", "Название не указано"),
"description": data.get("description", "Описание не указано"),
"key_skills": data.get("key_skills"),
"employment_type": self._validate_enum(
data.get("employment_type"), EmploymentType, EmploymentType.FULL_TIME
),
"experience": self._validate_enum(
data.get("experience"), Experience, Experience.BETWEEN_1_AND_3
),
"schedule": self._validate_enum(
data.get("schedule"), Schedule, Schedule.FULL_DAY
),
"company_name": data.get("company_name"),
"area_name": data.get("area_name"),
}
# Необязательные поля
optional_fields = [
"salary_from",
"salary_to",
"salary_currency",
"company_description",
"address",
"professional_roles",
"contacts_name",
"contacts_email",
"contacts_phone",
]
for field in optional_fields:
value = data.get(field)
if value and value != "null":
validated_data[field] = value
# Специальная обработка зарплаты
if data.get("salary_from"):
try:
validated_data["salary_from"] = int(data["salary_from"])
except (ValueError, TypeError):
pass
if data.get("salary_to"):
try:
validated_data["salary_to"] = int(data["salary_to"])
except (ValueError, TypeError):
pass
# Валюта по умолчанию
validated_data["salary_currency"] = data.get("salary_currency", "RUR")
return validated_data
def _validate_enum(self, value: str, enum_class, default_value):
"""Валидирует значение enum"""
if not value:
return default_value
# Проверяем, есть ли такое значение в enum
try:
return enum_class(value)
except ValueError:
logger.warning(
f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}"
)
return default_value
# Экземпляр сервиса
vacancy_parser_service = VacancyParserService()