299 lines
13 KiB
Python
299 lines
13 KiB
Python
import io
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any, Dict
|
||
|
||
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 as e:
|
||
# Альтернативный метод через pyth
|
||
try:
|
||
from pyth.plugins.rtf15.reader import Rtf15Reader
|
||
from pyth.plugins.plaintext.writer import PlaintextWriter
|
||
|
||
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() |