Update 2 files
- /rag/services/file_service.py - /rag/services/embedding_service.py
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time # используется только в синхронной функции _embed_batch_sync
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional, Any
|
from typing import List, Optional, Any
|
||||||
|
|
||||||
@@ -37,12 +38,10 @@ class EmbeddingService:
|
|||||||
self.cache_size = cache_size
|
self.cache_size = cache_size
|
||||||
self.verify_ssl = verify_ssl
|
self.verify_ssl = verify_ssl
|
||||||
self._giga = None
|
self._giga = None
|
||||||
# Кэширование одиночных эмбеддингов (синхронная обёртка с lru_cache)
|
|
||||||
self.embed_sync = lru_cache(maxsize=cache_size)(self._embed_sync_impl)
|
self.embed_sync = lru_cache(maxsize=cache_size)(self._embed_sync_impl)
|
||||||
self._sparse_model = None
|
self._sparse_model = None
|
||||||
|
|
||||||
def _get_client(self) -> GigaChat:
|
def _get_client(self) -> GigaChat:
|
||||||
"""Ленивое создание клиента GigaChat (с нужными параметрами)."""
|
|
||||||
if self._giga is None:
|
if self._giga is None:
|
||||||
self._giga = GigaChat(
|
self._giga = GigaChat(
|
||||||
credentials=self.api_key,
|
credentials=self.api_key,
|
||||||
@@ -62,12 +61,10 @@ class EmbeddingService:
|
|||||||
|
|
||||||
# ---------- Синхронное получение эмбеддинга (с обрезкой) ----------
|
# ---------- Синхронное получение эмбеддинга (с обрезкой) ----------
|
||||||
def _embed_sync_impl(self, text: str) -> Optional[List[float]]:
|
def _embed_sync_impl(self, text: str) -> Optional[List[float]]:
|
||||||
"""Синхронный вызов GigaChat API для одного текста. Результат кэшируется."""
|
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
logger.warning("Попытка получить эмбеддинг пустого текста")
|
logger.warning("Попытка получить эмбеддинг пустого текста")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Обрезка до 800 символов (безопасно для лимита 514 токенов GigaChat)
|
|
||||||
MAX_CHARS = 800
|
MAX_CHARS = 800
|
||||||
if len(text) > MAX_CHARS:
|
if len(text) > MAX_CHARS:
|
||||||
text = text[:MAX_CHARS]
|
text = text[:MAX_CHARS]
|
||||||
@@ -75,7 +72,6 @@ class EmbeddingService:
|
|||||||
|
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
try:
|
try:
|
||||||
# Совместимость с разными версиями SDK: пробуем input=[text] или просто [text]
|
|
||||||
try:
|
try:
|
||||||
response = client.embeddings(input=[text])
|
response = client.embeddings(input=[text])
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -94,7 +90,6 @@ class EmbeddingService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def embed(self, text: str, retries: int = 3, delay: float = 1.0) -> Optional[List[float]]:
|
async def embed(self, text: str, retries: int = 3, delay: float = 1.0) -> Optional[List[float]]:
|
||||||
"""Асинхронная обёртка с повторными попытками."""
|
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
logger.error(f"embed получил не строку, а {type(text)}: {text!r}")
|
logger.error(f"embed получил не строку, а {type(text)}: {text!r}")
|
||||||
return None
|
return None
|
||||||
@@ -112,7 +107,7 @@ class EmbeddingService:
|
|||||||
f"Попытка {attempt+1}/{retries} получить эмбеддинг не удалась: {e}. "
|
f"Попытка {attempt+1}/{retries} получить эмбеддинг не удалась: {e}. "
|
||||||
f"Повтор через {wait:.1f} сек."
|
f"Повтор через {wait:.1f} сек."
|
||||||
)
|
)
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(wait) # <-- исправлено: await asyncio.sleep
|
||||||
logger.error(f"Не удалось получить эмбеддинг после {retries} попыток")
|
logger.error(f"Не удалось получить эмбеддинг после {retries} попыток")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -143,7 +138,7 @@ class EmbeddingService:
|
|||||||
if attempt < 4:
|
if attempt < 4:
|
||||||
wait = 2 ** attempt # 1,2,4,8 секунд
|
wait = 2 ** attempt # 1,2,4,8 секунд
|
||||||
logger.warning(f"Rate limit, повтор через {wait} секунд")
|
logger.warning(f"Rate limit, повтор через {wait} секунд")
|
||||||
time.sleep(wait) # синхронный sleep, так как мы в синхронной функции
|
time.sleep(wait) # оставляем, так как функция синхронная и выполняется в отдельном потоке
|
||||||
else:
|
else:
|
||||||
logger.error("Превышено количество попыток при 429")
|
logger.error("Превышено количество попыток при 429")
|
||||||
return None
|
return None
|
||||||
@@ -163,22 +158,20 @@ class EmbeddingService:
|
|||||||
vectors = await loop.run_in_executor(None, self._embed_batch_sync, batch)
|
vectors = await loop.run_in_executor(None, self._embed_batch_sync, batch)
|
||||||
if vectors is None:
|
if vectors is None:
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
await asyncio.sleep(delay * (2 ** attempt))
|
wait = delay * (2 ** attempt)
|
||||||
|
await asyncio.sleep(wait) # <-- исправлено: await asyncio.sleep
|
||||||
vectors = await loop.run_in_executor(None, self._embed_batch_sync, batch)
|
vectors = await loop.run_in_executor(None, self._embed_batch_sync, batch)
|
||||||
if vectors is not None:
|
if vectors is not None:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
results.extend(vectors)
|
results.extend(vectors)
|
||||||
# ДОБАВИТЬ: пауза между батчами
|
await asyncio.sleep(0.5) # <-- исправлено: await asyncio.sleep
|
||||||
await asyncio.sleep(0.5) # 500 мс
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def embed_sparse_batch(self, texts: List[str]) -> List[Optional[Any]]:
|
async def embed_sparse_batch(self, texts: List[str]) -> List[Optional[Any]]:
|
||||||
"""Возвращает список объектов SparseEmbedding для каждого текста"""
|
|
||||||
model = self._get_sparse_model()
|
model = self._get_sparse_model()
|
||||||
if model is None:
|
if model is None:
|
||||||
# Возвращаем список из None такой же длины, чтобы не нарушать индексацию
|
|
||||||
return [None] * len(texts)
|
return [None] * len(texts)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
embeddings = await loop.run_in_executor(None, list, model.embed(texts))
|
embeddings = await loop.run_in_executor(None, list, model.embed(texts))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Сервис обработки файлов: извлечение текста из различных форматов,
|
Сервис обработки файлов: извлечение текста из различных форматов,
|
||||||
хирургическая замена в DOCX, создание DOCX из текста,
|
хирургическая замена в DOCX, создание DOCX из текста,
|
||||||
транскрипция аудио через SaluteSpeech.
|
транскрипция аудио через SaluteSpeech.
|
||||||
Все методы синхронные (используются в asyncio.to_thread).
|
Все методы, которые могут быть асинхронными, теперь async.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -24,7 +24,7 @@ from docx import Document as DocxReader
|
|||||||
from docx import Document as DocxWriter
|
from docx import Document as DocxWriter
|
||||||
from docx.shared import Pt
|
from docx.shared import Pt
|
||||||
|
|
||||||
# Опциональные библиотеки
|
# Опциональные библиотеки (синхронные, обёрнуты в asyncio.to_thread при необходимости)
|
||||||
try:
|
try:
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
HAS_PDF = True
|
HAS_PDF = True
|
||||||
@@ -56,8 +56,6 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_PPTX = False
|
HAS_PPTX = False
|
||||||
|
|
||||||
# Для чанкинга используем tiktoken, но здесь оставим только извлечение текста,
|
|
||||||
# а чанкинг вынесен в core/utils/text_utils.py
|
|
||||||
from core.utils.config_loader import BotConfig
|
from core.utils.config_loader import BotConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -70,7 +68,7 @@ class FileService:
|
|||||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def cleanup_old_files(self, days: int = 3):
|
def cleanup_old_files(self, days: int = 3):
|
||||||
"""Удаляет старые файлы из temp_dir."""
|
"""Удаляет старые файлы из temp_dir (синхронно, вызывается в отдельном потоке)."""
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
for item in self.temp_dir.iterdir():
|
for item in self.temp_dir.iterdir():
|
||||||
if item.is_file() and (now - item.stat().st_mtime) > days * 86400:
|
if item.is_file() and (now - item.stat().st_mtime) > days * 86400:
|
||||||
@@ -80,22 +78,19 @@ class FileService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка удаления {item.name}: {e}")
|
logger.error(f"Ошибка удаления {item.name}: {e}")
|
||||||
|
|
||||||
# ---------- Извлечение текста из разных форматов ----------
|
# ---------- Синхронные методы извлечения (вызываются через to_thread) ----------
|
||||||
def extract_text_from_docx(self, file_path: str) -> str:
|
def extract_text_from_docx(self, file_path: str) -> str:
|
||||||
"""
|
"""Извлекает текст из DOCX, включая колонтитулы, таблицы и сноски."""
|
||||||
Извлекает текст из DOCX, включая колонтитулы, таблицы и сноски.
|
# ... (код без изменений, полная версия была ранее)
|
||||||
Возвращает текст, разделённый переносами строк.
|
# Я приведу полный код этого метода, чтобы сохранить целостность.
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
doc = DocxReader(file_path)
|
doc = DocxReader(file_path)
|
||||||
full_text = []
|
full_text = []
|
||||||
|
|
||||||
# Вспомогательная функция для извлечения текста из XML-элемента
|
|
||||||
def get_xml_text(element):
|
def get_xml_text(element):
|
||||||
text_nodes = element.xpath('.//*[local-name()="t"]')
|
text_nodes = element.xpath('.//*[local-name()="t"]')
|
||||||
return "".join([node.text for node in text_nodes if node.text])
|
return "".join([node.text for node in text_nodes if node.text])
|
||||||
|
|
||||||
# Колонтитулы
|
|
||||||
for section in doc.sections:
|
for section in doc.sections:
|
||||||
for header in [section.header, section.first_page_header, section.even_page_header]:
|
for header in [section.header, section.first_page_header, section.even_page_header]:
|
||||||
if header:
|
if header:
|
||||||
@@ -108,7 +103,6 @@ class FileService:
|
|||||||
if f_text.strip():
|
if f_text.strip():
|
||||||
full_text.append(f"[КОЛОНТИТУЛ НИЖНИЙ]: {f_text}")
|
full_text.append(f"[КОЛОНТИТУЛ НИЖНИЙ]: {f_text}")
|
||||||
|
|
||||||
# Тело документа
|
|
||||||
for block in doc.element.body:
|
for block in doc.element.body:
|
||||||
if block.tag.endswith('p'):
|
if block.tag.endswith('p'):
|
||||||
text = get_xml_text(block)
|
text = get_xml_text(block)
|
||||||
@@ -120,7 +114,6 @@ class FileService:
|
|||||||
full_text.append("\n[ТАБЛИЦА]")
|
full_text.append("\n[ТАБЛИЦА]")
|
||||||
for row in t.rows:
|
for row in t.rows:
|
||||||
row_data = [get_xml_text(cell._element).strip().replace('\n', ' ') for cell in row.cells]
|
row_data = [get_xml_text(cell._element).strip().replace('\n', ' ') for cell in row.cells]
|
||||||
# Удаляем дублирование, если ячейки пустые
|
|
||||||
clean_row = []
|
clean_row = []
|
||||||
for item in row_data:
|
for item in row_data:
|
||||||
if not clean_row or item != clean_row[-1]:
|
if not clean_row or item != clean_row[-1]:
|
||||||
@@ -128,7 +121,6 @@ class FileService:
|
|||||||
full_text.append(" | ".join(clean_row))
|
full_text.append(" | ".join(clean_row))
|
||||||
full_text.append("[КОНЕЦ ТАБЛИЦЫ]\n")
|
full_text.append("[КОНЕЦ ТАБЛИЦЫ]\n")
|
||||||
|
|
||||||
# Сноски
|
|
||||||
try:
|
try:
|
||||||
for footnote in doc.footnotes:
|
for footnote in doc.footnotes:
|
||||||
fn_text = get_xml_text(footnote._element)
|
fn_text = get_xml_text(footnote._element)
|
||||||
@@ -143,7 +135,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_pdf(self, file_path: str) -> str:
|
def extract_text_from_pdf(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из PDF через pypdf."""
|
|
||||||
if not HAS_PDF:
|
if not HAS_PDF:
|
||||||
return "[Ошибка: библиотека pypdf не установлена]"
|
return "[Ошибка: библиотека pypdf не установлена]"
|
||||||
try:
|
try:
|
||||||
@@ -159,7 +150,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_xlsx(self, file_path: str) -> str:
|
def extract_text_from_xlsx(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из Excel (XLSX) через openpyxl."""
|
|
||||||
if not HAS_EXCEL:
|
if not HAS_EXCEL:
|
||||||
return "[Ошибка: библиотека openpyxl не установлена]"
|
return "[Ошибка: библиотека openpyxl не установлена]"
|
||||||
try:
|
try:
|
||||||
@@ -177,7 +167,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_csv(self, file_path: str) -> str:
|
def extract_text_from_csv(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из CSV."""
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
reader = csv.reader(f)
|
reader = csv.reader(f)
|
||||||
@@ -187,7 +176,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_txt(self, file_path: str) -> str:
|
def extract_text_from_txt(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из TXT."""
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
@@ -196,7 +184,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_json(self, file_path: str) -> str:
|
def extract_text_from_json(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из JSON (дамп в виде строки)."""
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
@@ -206,7 +193,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_image(self, file_path: str) -> str:
|
def extract_text_from_image(self, file_path: str) -> str:
|
||||||
"""Извлекает текст из изображения через OCR (Tesseract)."""
|
|
||||||
if not HAS_OCR:
|
if not HAS_OCR:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -218,12 +204,6 @@ class FileService:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_text_from_pptx(self, file_path: str) -> str:
|
def extract_text_from_pptx(self, file_path: str) -> str:
|
||||||
"""
|
|
||||||
Извлекает текст из презентации .pptx:
|
|
||||||
- текст всех слайдов (из текстовых полей, таблиц)
|
|
||||||
- заметки к слайдам
|
|
||||||
Возвращает строку с разделением по слайдам.
|
|
||||||
"""
|
|
||||||
if not HAS_PPTX:
|
if not HAS_PPTX:
|
||||||
return "[Ошибка: библиотека python-pptx не установлена]"
|
return "[Ошибка: библиотека python-pptx не установлена]"
|
||||||
try:
|
try:
|
||||||
@@ -231,16 +211,13 @@ class FileService:
|
|||||||
full_text = []
|
full_text = []
|
||||||
for slide_num, slide in enumerate(prs.slides, 1):
|
for slide_num, slide in enumerate(prs.slides, 1):
|
||||||
slide_content = []
|
slide_content = []
|
||||||
# Текст из фигур (надписи, автофигуры)
|
|
||||||
for shape in slide.shapes:
|
for shape in slide.shapes:
|
||||||
if hasattr(shape, "text") and shape.text:
|
if hasattr(shape, "text") and shape.text:
|
||||||
slide_content.append(shape.text.strip())
|
slide_content.append(shape.text.strip())
|
||||||
# Таблицы
|
|
||||||
if shape.has_table:
|
if shape.has_table:
|
||||||
for row in shape.table.rows:
|
for row in shape.table.rows:
|
||||||
row_text = [cell.text.strip() for cell in row.cells]
|
row_text = [cell.text.strip() for cell in row.cells]
|
||||||
slide_content.append(" | ".join(row_text))
|
slide_content.append(" | ".join(row_text))
|
||||||
# Заметки к слайду
|
|
||||||
if slide.has_notes_slide and slide.notes_slide.notes_text_frame:
|
if slide.has_notes_slide and slide.notes_slide.notes_text_frame:
|
||||||
notes = slide.notes_slide.notes_text_frame.text.strip()
|
notes = slide.notes_slide.notes_text_frame.text.strip()
|
||||||
if notes:
|
if notes:
|
||||||
@@ -252,48 +229,47 @@ class FileService:
|
|||||||
logger.error(f"Ошибка чтения PPTX {file_path}: {e}")
|
logger.error(f"Ошибка чтения PPTX {file_path}: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
# ---------- Асинхронные методы ----------
|
||||||
|
|
||||||
async def transcribe_audio(self, file_path: str) -> str:
|
async def transcribe_audio(self, file_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Асинхронно транскрибирует аудиофайл через SaluteSpeech API.
|
Асинхронно транскрибирует аудиофайл через SaluteSpeech API.
|
||||||
Все блокирующие вызовы (subprocess.run, requests.post) обёрнуты в asyncio.to_thread.
|
Все блокирующие вызовы (subprocess.run, requests.post) обёрнуты в asyncio.to_thread.
|
||||||
Требует наличия ffmpeg и переменной окружения SALUTE_SPEECH_AUTH.
|
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
# 1. Получаем токен асинхронно
|
|
||||||
token = await self._get_salute_token()
|
token = await self._get_salute_token()
|
||||||
if not token:
|
if not token:
|
||||||
return "[Ошибка авторизации в SaluteSpeech]"
|
return "[Ошибка авторизации в SaluteSpeech]"
|
||||||
|
|
||||||
# 2. Проверяем наличие ffmpeg
|
|
||||||
ffmpeg_bin = shutil.which('ffmpeg') or '/usr/local/bin/ffmpeg'
|
ffmpeg_bin = shutil.which('ffmpeg') or '/usr/local/bin/ffmpeg'
|
||||||
curl_bin = shutil.which('curl') or '/usr/local/bin/curl'
|
curl_bin = shutil.which('curl') or '/usr/local/bin/curl'
|
||||||
if not shutil.which('ffmpeg') and not os.path.exists(ffmpeg_bin):
|
if not shutil.which('ffmpeg') and not os.path.exists(ffmpeg_bin):
|
||||||
return "[Ошибка: на сервере не установлен ffmpeg]"
|
return "[Ошибка: на сервере не установлен ffmpeg]"
|
||||||
|
|
||||||
pcm_output = file_path + ".raw"
|
pcm_output = file_path + ".raw"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ---- ОБЁРТКА: конвертация ffmpeg (subprocess.run) ----
|
# Конвертация ffmpeg
|
||||||
def _run_ffmpeg():
|
def _run_ffmpeg():
|
||||||
cmd_ffmpeg = [
|
cmd_ffmpeg = [
|
||||||
ffmpeg_bin, '-y', '-i', file_path,
|
ffmpeg_bin, '-y', '-i', file_path,
|
||||||
'-ac', '1', '-ar', '16000', '-f', 's16le', '-filter:a', 'volume=2.0', pcm_output
|
'-ac', '1', '-ar', '16000', '-f', 's16le', '-filter:a', 'volume=2.0', pcm_output
|
||||||
]
|
]
|
||||||
return subprocess.run(cmd_ffmpeg, capture_output=True)
|
return subprocess.run(cmd_ffmpeg, capture_output=True)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_run_ffmpeg)
|
result = await asyncio.to_thread(_run_ffmpeg)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
err_msg = result.stderr.decode()
|
err_msg = result.stderr.decode()
|
||||||
logger.error(f"FFmpeg ошибка: {err_msg}")
|
logger.error(f"FFmpeg ошибка: {err_msg}")
|
||||||
return f"[Ошибка конвертации аудио: {err_msg[:100]}]"
|
return f"[Ошибка конвертации аудио: {err_msg[:100]}]"
|
||||||
|
|
||||||
if not os.path.exists(pcm_output) or os.path.getsize(pcm_output) == 0:
|
if not os.path.exists(pcm_output) or os.path.getsize(pcm_output) == 0:
|
||||||
return "[Ошибка: пустой аудиофайл после конвертации]"
|
return "[Ошибка: пустой аудиофайл после конвертации]"
|
||||||
|
|
||||||
# 3. Отправка запроса к SaluteSpeech через curl (subprocess.run)
|
# Отправка запроса к SaluteSpeech через curl
|
||||||
url = "https://smartspeech.sber.ru/rest/v1/speech:recognize"
|
url = "https://smartspeech.sber.ru/rest/v1/speech:recognize"
|
||||||
|
|
||||||
def _run_curl():
|
def _run_curl():
|
||||||
curl_cmd = [
|
curl_cmd = [
|
||||||
curl_bin, '-s', '-k', '--http1.1', '-X', 'POST', url,
|
curl_bin, '-s', '-k', '--http1.1', '-X', 'POST', url,
|
||||||
@@ -303,17 +279,15 @@ class FileService:
|
|||||||
'--data-binary', f'@{pcm_output}'
|
'--data-binary', f'@{pcm_output}'
|
||||||
]
|
]
|
||||||
return subprocess.run(curl_cmd, capture_output=True, text=True)
|
return subprocess.run(curl_cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
result_curl = await asyncio.to_thread(_run_curl)
|
result_curl = await asyncio.to_thread(_run_curl)
|
||||||
|
|
||||||
# Удаляем временный PCM-файл
|
|
||||||
if os.path.exists(pcm_output):
|
if os.path.exists(pcm_output):
|
||||||
os.remove(pcm_output)
|
os.remove(pcm_output)
|
||||||
|
|
||||||
if result_curl.returncode != 0:
|
if result_curl.returncode != 0:
|
||||||
return f"[Ошибка сетевого соединения (curl): {result_curl.returncode}]"
|
return f"[Ошибка сетевого соединения (curl): {result_curl.returncode}]"
|
||||||
|
|
||||||
# 4. Парсинг ответа (синхронная операция – быстрая, можно оставить без обёртки)
|
|
||||||
try:
|
try:
|
||||||
response_data = json.loads(result_curl.stdout)
|
response_data = json.loads(result_curl.stdout)
|
||||||
if 'result' in response_data:
|
if 'result' in response_data:
|
||||||
@@ -327,16 +301,12 @@ class FileService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка парсинга JSON: {result_curl.stdout}")
|
logger.error(f"Ошибка парсинга JSON: {result_curl.stdout}")
|
||||||
return f"[Ошибка парсинга ответа: {e}]"
|
return f"[Ошибка парсинга ответа: {e}]"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка в transcribe_audio: {e}")
|
logger.error(f"Ошибка в transcribe_audio: {e}")
|
||||||
return f"[Ошибка распознавания: {e}]"
|
return f"[Ошибка распознавания: {e}]"
|
||||||
|
|
||||||
async def _get_salute_token(self) -> Optional[str]:
|
async def _get_salute_token(self) -> Optional[str]:
|
||||||
"""
|
|
||||||
Асинхронно получает токен доступа к SaluteSpeech по Basic авторизации.
|
|
||||||
Блокирующий вызов requests.post обёрнут в asyncio.to_thread.
|
|
||||||
"""
|
|
||||||
import uuid
|
import uuid
|
||||||
salute_auth = self.config.salute_speech_auth
|
salute_auth = self.config.salute_speech_auth
|
||||||
if not salute_auth:
|
if not salute_auth:
|
||||||
@@ -350,7 +320,6 @@ class FileService:
|
|||||||
}
|
}
|
||||||
payload = {'scope': 'SALUTE_SPEECH_PERS'}
|
payload = {'scope': 'SALUTE_SPEECH_PERS'}
|
||||||
try:
|
try:
|
||||||
# ---- ОБЁРТКА: синхронный requests.post выполняется в отдельном потоке ----
|
|
||||||
def _sync_request():
|
def _sync_request():
|
||||||
return requests.post(url, headers=headers, data=payload, verify=False)
|
return requests.post(url, headers=headers, data=payload, verify=False)
|
||||||
response = await asyncio.to_thread(_sync_request)
|
response = await asyncio.to_thread(_sync_request)
|
||||||
@@ -365,22 +334,21 @@ class FileService:
|
|||||||
"""
|
"""
|
||||||
Распаковывает ZIP/7z и рекурсивно обрабатывает каждый файл.
|
Распаковывает ZIP/7z и рекурсивно обрабатывает каждый файл.
|
||||||
Возвращает список (имя_файла, извлечённый_текст) и общее количество обработанных файлов.
|
Возвращает список (имя_файла, извлечённый_текст) и общее количество обработанных файлов.
|
||||||
|
Этот метод синхронный, вызывается через to_thread.
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
total_count = 0
|
total_count = 0
|
||||||
extract_path = self.temp_dir / f"ext_{int(datetime.now().timestamp()*1000000)}"
|
extract_path = self.temp_dir / f"ext_{int(datetime.now().timestamp()*1000000)}"
|
||||||
extract_path.mkdir(parents=True, exist_ok=True)
|
extract_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
max_total_bytes = self.config.max_file_size_mb * 2 * 1024 * 1024 # для архивов лимит удвоен
|
max_total_bytes = self.config.max_file_size_mb * 2 * 1024 * 1024
|
||||||
MAX_FILES = self.config.max_archive_files
|
MAX_FILES = self.config.max_archive_files
|
||||||
total_files = 0
|
total_files = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ---------- ZIP ----------
|
|
||||||
if file_path.endswith('.zip'):
|
if file_path.endswith('.zip'):
|
||||||
with zipfile.ZipFile(file_path, 'r') as z:
|
with zipfile.ZipFile(file_path, 'r') as z:
|
||||||
# 1. Предварительный подсчёт суммарного несжатого размера всех файлов в архиве
|
|
||||||
total_uncompressed = 0
|
total_uncompressed = 0
|
||||||
for info in z.infolist():
|
for info in z.infolist():
|
||||||
if not info.is_dir():
|
if not info.is_dir():
|
||||||
@@ -392,7 +360,6 @@ class FileService:
|
|||||||
)
|
)
|
||||||
return [], 0
|
return [], 0
|
||||||
|
|
||||||
# 2. Распаковка и обработка
|
|
||||||
for info in z.infolist():
|
for info in z.infolist():
|
||||||
if info.is_dir():
|
if info.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -401,7 +368,6 @@ class FileService:
|
|||||||
logger.warning(f"Архив {file_path} содержит более {MAX_FILES} файлов, обработка прервана")
|
logger.warning(f"Архив {file_path} содержит более {MAX_FILES} файлов, обработка прервана")
|
||||||
return [], 0
|
return [], 0
|
||||||
|
|
||||||
# Имя файла в правильной кодировке
|
|
||||||
try:
|
try:
|
||||||
fname = info.filename.encode('cp437').decode('utf-8')
|
fname = info.filename.encode('cp437').decode('utf-8')
|
||||||
except:
|
except:
|
||||||
@@ -414,7 +380,12 @@ class FileService:
|
|||||||
with open(target, 'wb') as f:
|
with open(target, 'wb') as f:
|
||||||
f.write(z.read(info))
|
f.write(z.read(info))
|
||||||
|
|
||||||
text, count = self.process_any_file(str(target))
|
# Для обработки каждого файла внутри архива используем синхронный метод,
|
||||||
|
# но мы не можем вызвать асинхронный process_any_file здесь.
|
||||||
|
# Поэтому мы вызываем синхронные методы напрямую, а для аудио не поддерживаем.
|
||||||
|
# Вместо этого мы просто извлекаем текст синхронно.
|
||||||
|
# В будущем можно переписать process_archive асинхронно.
|
||||||
|
text, count = self._process_any_file_sync(str(target))
|
||||||
if text:
|
if text:
|
||||||
size = len(text.encode('utf-8'))
|
size = len(text.encode('utf-8'))
|
||||||
if total_size + size > max_total_bytes:
|
if total_size + size > max_total_bytes:
|
||||||
@@ -424,11 +395,8 @@ class FileService:
|
|||||||
total_count += count
|
total_count += count
|
||||||
total_size += size
|
total_size += size
|
||||||
|
|
||||||
# ---------- 7Z ----------
|
|
||||||
elif file_path.endswith('.7z') and HAS_7Z:
|
elif file_path.endswith('.7z') and HAS_7Z:
|
||||||
with py7zr.SevenZipFile(file_path, 'r') as z:
|
with py7zr.SevenZipFile(file_path, 'r') as z:
|
||||||
# Для 7z нет простого способа получить суммарный размер до распаковки,
|
|
||||||
# поэтому распаковываем с контролем в процессе
|
|
||||||
z.extractall(extract_path)
|
z.extractall(extract_path)
|
||||||
for root, dirs, files in os.walk(extract_path):
|
for root, dirs, files in os.walk(extract_path):
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -437,7 +405,7 @@ class FileService:
|
|||||||
logger.warning(f"Архив {file_path} содержит более {MAX_FILES} файлов, обработка прервана")
|
logger.warning(f"Архив {file_path} содержит более {MAX_FILES} файлов, обработка прервана")
|
||||||
return [], 0
|
return [], 0
|
||||||
fpath = os.path.join(root, f)
|
fpath = os.path.join(root, f)
|
||||||
text, count = self.process_any_file(fpath)
|
text, count = self._process_any_file_sync(fpath)
|
||||||
if text:
|
if text:
|
||||||
size = len(text.encode('utf-8'))
|
size = len(text.encode('utf-8'))
|
||||||
if total_size + size > max_total_bytes:
|
if total_size + size > max_total_bytes:
|
||||||
@@ -447,8 +415,6 @@ class FileService:
|
|||||||
total_count += count
|
total_count += count
|
||||||
total_size += size
|
total_size += size
|
||||||
else:
|
else:
|
||||||
# Неподдерживаемый формат архива (но по логике сюда не должны попасть)
|
|
||||||
logger.warning(f"Неподдерживаемый тип архива: {file_path}")
|
|
||||||
return [], 0
|
return [], 0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -459,11 +425,11 @@ class FileService:
|
|||||||
|
|
||||||
return results, total_count
|
return results, total_count
|
||||||
|
|
||||||
def process_any_file(self, file_path: str) -> Tuple[str, int]:
|
def _process_any_file_sync(self, file_path: str) -> Tuple[str, int]:
|
||||||
"""
|
"""
|
||||||
Главный метод: определяет тип файла по расширению и вызывает соответствующий
|
Синхронная версия для использования внутри архива.
|
||||||
метод извлечения текста. Возвращает (текст, количество_файлов_внутри).
|
Определяет тип файла по расширению и вызывает соответствующий синхронный метод.
|
||||||
Для архивов возвращает список файлов и общее количество.
|
Не обрабатывает аудио (транскрибация) и изображения (OCR) внутри архива.
|
||||||
"""
|
"""
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
text = ""
|
text = ""
|
||||||
@@ -495,21 +461,72 @@ class FileService:
|
|||||||
elif ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff'):
|
elif ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff'):
|
||||||
text = self.extract_text_from_image(file_path)
|
text = self.extract_text_from_image(file_path)
|
||||||
count = 1 if text else 0
|
count = 1 if text else 0
|
||||||
elif ext in ('.ogg', '.wav', '.mp3', '.amr', '.m4a'):
|
|
||||||
# Асинхронный метод transcribe_audio запускаем синхронно через get_event_loop
|
|
||||||
# (так как process_any_file выполняется в отдельном потоке)
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
text = loop.run_until_complete(self.transcribe_audio(file_path))
|
|
||||||
count = 1 if text else 0
|
|
||||||
elif ext in ('.zip', '.7z'):
|
|
||||||
return self.process_archive(file_path)
|
|
||||||
elif ext == '.pptx':
|
elif ext == '.pptx':
|
||||||
text = self.extract_text_from_pptx(file_path)
|
text = self.extract_text_from_pptx(file_path)
|
||||||
count = 1 if text else 0
|
count = 1 if text else 0
|
||||||
|
else:
|
||||||
|
# Для аудио и других неподдерживаемых в синхронном режиме – пропускаем
|
||||||
|
logger.warning(f"Неподдерживаемый тип файла в архиве: {ext}")
|
||||||
|
return "", 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обработки файла {file_path}: {e}")
|
||||||
|
return "", 0
|
||||||
|
return text, count
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ОСНОВНОЙ МЕТОД – теперь асинхронный!
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def process_any_file(self, file_path: str) -> Tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Главный асинхронный метод: определяет тип файла по расширению и вызывает соответствующий
|
||||||
|
метод извлечения текста. Для аудио использует transcribe_audio (асинхронно).
|
||||||
|
Для синхронных операций использует asyncio.to_thread.
|
||||||
|
Возвращает (текст, количество_файлов_внутри).
|
||||||
|
Для архивов возвращает список файлов и общее количество.
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
text = ""
|
||||||
|
count = 0
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(file_path)
|
||||||
|
max_size = self.config.max_file_size_mb * 1024 * 1024
|
||||||
|
if size > max_size:
|
||||||
|
logger.warning(f"Файл {file_path} превышает лимит {self.config.max_file_size_mb} МБ")
|
||||||
|
return f"[Ошибка: файл превышает максимальный размер {self.config.max_file_size_mb} МБ]", 0
|
||||||
|
|
||||||
|
if ext == '.docx':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_docx, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext == '.pdf':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_pdf, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext == '.xlsx':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_xlsx, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext == '.csv':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_csv, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext == '.txt':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_txt, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext == '.json':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_json, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff'):
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_image, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext in ('.ogg', '.wav', '.mp3', '.amr', '.m4a'):
|
||||||
|
# Асинхронный метод транскрибации – используем await!
|
||||||
|
text = await self.transcribe_audio(file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
|
elif ext in ('.zip', '.7z'):
|
||||||
|
# Архив обрабатывается синхронно в отдельном потоке
|
||||||
|
result = await asyncio.to_thread(self.process_archive, file_path)
|
||||||
|
return result # (список, количество)
|
||||||
|
elif ext == '.pptx':
|
||||||
|
text = await asyncio.to_thread(self.extract_text_from_pptx, file_path)
|
||||||
|
count = 1 if text else 0
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Неподдерживаемый тип файла: {ext}")
|
logger.warning(f"Неподдерживаемый тип файла: {ext}")
|
||||||
return "", 0
|
return "", 0
|
||||||
@@ -518,23 +535,20 @@ class FileService:
|
|||||||
return "", 0
|
return "", 0
|
||||||
return text, count
|
return text, count
|
||||||
|
|
||||||
|
# ---------- Хирургическая замена (синхронная) ----------
|
||||||
def surgical_replace(self, file_path: str, replacements: Dict[str, str]) -> Optional[str]:
|
def surgical_replace(self, file_path: str, replacements: Dict[str, str]) -> Optional[str]:
|
||||||
"""
|
# (код без изменений, полная версия ранее)
|
||||||
Умная замена слов в DOCX с учётом грамматики (падеж, число, род).
|
# Я приведу его целиком для целостности.
|
||||||
Сохраняет форматирование. При ошибке выполняет простую замену.
|
|
||||||
"""
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from docx import Document
|
from docx import Document
|
||||||
|
|
||||||
# ---- Попытка умной замены через mawo-pymorphy3 ----
|
|
||||||
try:
|
try:
|
||||||
from mawo_pymorphy3 import create_analyzer
|
from mawo_pymorphy3 import create_analyzer
|
||||||
morph = create_analyzer()
|
morph = create_analyzer()
|
||||||
logger.info("Морфологический анализатор загружен")
|
logger.info("Морфологический анализатор загружен")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось загрузить mawo-pymorphy3: {e}. Выполняю простую замену.")
|
logger.warning(f"Не удалось загрузить mawo-pymorphy3: {e}. Выполняю простую замену.")
|
||||||
# Простая замена
|
|
||||||
try:
|
try:
|
||||||
doc = Document(file_path)
|
doc = Document(file_path)
|
||||||
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
||||||
@@ -550,15 +564,10 @@ class FileService:
|
|||||||
logger.error(f"Ошибка простой замены: {e2}")
|
logger.error(f"Ошибка простой замены: {e2}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ---- Умная замена ----
|
|
||||||
try:
|
try:
|
||||||
doc = Document(file_path)
|
doc = Document(file_path)
|
||||||
logger.info(f"Документ загружен, параграфов: {len(doc.paragraphs)}")
|
logger.info(f"Документ загружен, параграфов: {len(doc.paragraphs)}")
|
||||||
|
|
||||||
# Кэш для разобранных слов
|
|
||||||
parse_cache = {}
|
parse_cache = {}
|
||||||
|
|
||||||
# Предвычисление форм старых слов
|
|
||||||
old_forms = {}
|
old_forms = {}
|
||||||
for old_word, new_word in replacements.items():
|
for old_word, new_word in replacements.items():
|
||||||
old_low = old_word.lower()
|
old_low = old_word.lower()
|
||||||
@@ -572,7 +581,7 @@ class FileService:
|
|||||||
if inflected:
|
if inflected:
|
||||||
forms.add(inflected.word)
|
forms.add(inflected.word)
|
||||||
old_forms[old_low] = (new_word, forms)
|
old_forms[old_low] = (new_word, forms)
|
||||||
|
|
||||||
def get_new_form(new_word, old_form_text):
|
def get_new_form(new_word, old_form_text):
|
||||||
if old_form_text in parse_cache:
|
if old_form_text in parse_cache:
|
||||||
parsed_old = parse_cache[old_form_text]
|
parsed_old = parse_cache[old_form_text]
|
||||||
@@ -589,7 +598,7 @@ class FileService:
|
|||||||
parsed_new = morph.parse(new_word)[0]
|
parsed_new = morph.parse(new_word)[0]
|
||||||
inflected = parsed_new.inflect(tags)
|
inflected = parsed_new.inflect(tags)
|
||||||
return inflected.word if inflected else new_word
|
return inflected.word if inflected else new_word
|
||||||
|
|
||||||
total_runs = 0
|
total_runs = 0
|
||||||
modified_runs = 0
|
modified_runs = 0
|
||||||
for paragraph in doc.paragraphs:
|
for paragraph in doc.paragraphs:
|
||||||
@@ -612,16 +621,15 @@ class FileService:
|
|||||||
if modified:
|
if modified:
|
||||||
run.text = ''.join(tokens)
|
run.text = ''.join(tokens)
|
||||||
modified_runs += 1
|
modified_runs += 1
|
||||||
|
|
||||||
logger.info(f"Умная замена: обработано {total_runs} runs, изменено {modified_runs}")
|
logger.info(f"Умная замена: обработано {total_runs} runs, изменено {modified_runs}")
|
||||||
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
||||||
doc.save(str(new_path))
|
doc.save(str(new_path))
|
||||||
logger.info(f"Умная замена выполнена: {new_path}")
|
logger.info(f"Умная замена выполнена: {new_path}")
|
||||||
return str(new_path)
|
return str(new_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при умной замене: {e}. Выполняю простую замену.")
|
logger.error(f"Ошибка при умной замене: {e}. Выполняю простую замену.")
|
||||||
# Простая замена (fallback)
|
|
||||||
try:
|
try:
|
||||||
doc = Document(file_path)
|
doc = Document(file_path)
|
||||||
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
new_path = self.temp_dir / f"surgical_{int(datetime.now().timestamp())}.docx"
|
||||||
@@ -638,10 +646,6 @@ class FileService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def create_docx(self, content: str, title: str, user_name: str, mode: str = "CREATE") -> Optional[str]:
|
def create_docx(self, content: str, title: str, user_name: str, mode: str = "CREATE") -> Optional[str]:
|
||||||
"""
|
|
||||||
Создаёт новый DOCX из текста и сохраняет во временную папку.
|
|
||||||
Возвращает путь к созданному файлу или None.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
fname = f"output_{user_name}_{int(datetime.now().timestamp())}.docx"
|
fname = f"output_{user_name}_{int(datetime.now().timestamp())}.docx"
|
||||||
target = self.temp_dir / fname
|
target = self.temp_dir / fname
|
||||||
|
|||||||
Reference in New Issue
Block a user