From b2d2061a83404febe25b8116750717bb533a6145 Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 11 Aug 2026 20:17:33 +0500 Subject: [PATCH] =?UTF-8?q?refactor(indexer):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D1=84=D1=80=D0=B0=D0=B3=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B2=20app.fragments=20+=205=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _split_into_fragments жила в тяжёлой Celery-задаче (celery/sqlalchemy/minio) и не тестировалась, хотя именно она определяет, ЧТО проверяется на плагиат. Вынес в чистый app.fragments.split_into_fragments — тестируется изолированно: - пустой/пробельный текст → []; - текст короче 20 слов → отбрасывается порогом длины; - короткий текст → один фрагмент на весь объём, start=0; - скользящее окно: перекрытие ровно (window-step) слов, хвост==голова соседа; - start/end — корректные символьные офсеты в исходный текст. Поведение сохранено 1:1. Добавлен в mypy-гейт. Тестов всего: 66. Co-Authored-By: Claude Opus 4.8 --- scripts/run_lint.sh | 2 +- services/worker-indexer/app/fragments.py | 56 +++++++++++++++++++ services/worker-indexer/app/tasks/index.py | 53 +----------------- .../worker-indexer/tests/test_fragments.py | 43 ++++++++++++++ 4 files changed, 102 insertions(+), 52 deletions(-) create mode 100644 services/worker-indexer/app/fragments.py create mode 100644 services/worker-indexer/tests/test_fragments.py diff --git a/scripts/run_lint.sh b/scripts/run_lint.sh index b823717..6e1c753 100755 --- a/scripts/run_lint.sh +++ b/scripts/run_lint.sh @@ -25,7 +25,7 @@ docker run --rm \ ruff check services/ scripts/ echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)' - ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ ) + ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py ) ( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ ) ( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py ) " diff --git a/services/worker-indexer/app/fragments.py b/services/worker-indexer/app/fragments.py new file mode 100644 index 0000000..9d66d18 --- /dev/null +++ b/services/worker-indexer/app/fragments.py @@ -0,0 +1,56 @@ +"""Разбиение текста документа на фрагменты для проверки плагиата — чистая логика. + +Скользящее окно по словам с перекрытием. Вынесено из Celery-задачи, чтобы +границы фрагментов (от них зависит, что именно проверяется на плагиат) можно +было тестировать изолированно. +""" + +from typing import Any + + +def split_into_fragments( + text: str, + window: int = 200, + overlap: int = 50, +) -> list[dict[str, Any]]: + """Разбить текст на фрагменты скользящим окном с перекрытием. + + Args: + text: Исходный текст + window: Размер окна в словах + overlap: Перекрытие между соседними фрагментами в словах + + Returns: + Список словарей {"text": str, "start": int, "end": int}, где start/end — + позиции в символах. Фрагменты короче 20 слов отбрасываются. + """ + words = text.split() + if not words: + return [] + + fragments: list[dict[str, Any]] = [] + step = window - overlap + + # Позиция первого символа каждого слова (слова разделены одним пробелом) + char_positions = [] + pos = 0 + for word in words: + char_positions.append(pos) + pos += len(word) + 1 # +1 для пробела + + for i in range(0, max(1, len(words) - window + 1), step): + chunk_words = words[i : i + window] + if len(chunk_words) < 20: # слишком короткий фрагмент — пропустить + continue + + start_char = char_positions[i] + end_idx = min(i + window - 1, len(words) - 1) + end_char = char_positions[end_idx] + len(words[end_idx]) + + fragments.append({ + "text": " ".join(chunk_words), + "start": start_char, + "end": end_char, + }) + + return fragments diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index 28b66f0..3aa02ec 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -16,60 +16,11 @@ from app.config import settings from app.db import db_session, get_minio, update_task_status from app.extractors.docx import extract_text_from_docx, extract_text_from_txt from app.extractors.pdf import extract_text_from_pdf +from app.fragments import split_into_fragments logger = get_task_logger(__name__) -def _split_into_fragments( - text: str, - window: int = 200, - overlap: int = 50, -) -> list[dict[str, Any]]: - """ - Разбить текст на фрагменты для проверки плагиата. - - Использует скользящее окно с перекрытием. - - Args: - text: Исходный текст - window: Размер окна в словах - overlap: Перекрытие между фрагментами в словах - - Returns: - Список словарей {"text": str, "start": int, "end": int} - """ - words = text.split() - if not words: - return [] - - fragments = [] - step = window - overlap - char_positions = [] - - # Вычислить позиции символов для каждого слова - pos = 0 - for word in words: - char_positions.append(pos) - pos += len(word) + 1 # +1 для пробела - - for i in range(0, max(1, len(words) - window + 1), step): - chunk_words = words[i : i + window] - if len(chunk_words) < 20: # Пропустить слишком короткие фрагменты - continue - - start_char = char_positions[i] - end_idx = min(i + window - 1, len(words) - 1) - end_char = char_positions[end_idx] + len(words[end_idx]) - - fragments.append({ - "text": " ".join(chunk_words), - "start": start_char, - "end": end_char, - }) - - return fragments - - @celery_app.task( name="index.extract_and_check", bind=True, @@ -133,7 +84,7 @@ def extract_and_check( _stage_work(task_id, minio_key, filename, text, word_count) # Разбить на фрагменты - fragments = _split_into_fragments( + fragments = split_into_fragments( text, window=settings.FRAGMENT_WINDOW_WORDS, overlap=settings.FRAGMENT_OVERLAP_WORDS, diff --git a/services/worker-indexer/tests/test_fragments.py b/services/worker-indexer/tests/test_fragments.py new file mode 100644 index 0000000..b208798 --- /dev/null +++ b/services/worker-indexer/tests/test_fragments.py @@ -0,0 +1,43 @@ +"""Юнит-тесты разбиения текста на фрагменты (определяет, что проверяется).""" + +from app.fragments import split_into_fragments + + +def _text(n: int) -> str: + """n слов одинаковой длины: 'w000 w001 ... ' (по 4 символа + пробел).""" + return " ".join(f"w{i:03d}" for i in range(n)) + + +def test_empty_text_returns_empty(): + assert split_into_fragments("") == [] + assert split_into_fragments(" ") == [] + + +def test_text_shorter_than_min_words_is_dropped(): + # < 20 слов → единственный кандидат отбрасывается порогом длины + assert split_into_fragments(_text(19), window=200, overlap=50) == [] + + +def test_short_text_single_fragment_covers_all(): + frags = split_into_fragments(_text(30), window=200, overlap=50) + assert len(frags) == 1 + assert frags[0]["start"] == 0 + assert frags[0]["text"] == _text(30) + + +def test_sliding_window_overlap(): + frags = split_into_fragments(_text(50), window=25, overlap=5) + # step = window - overlap = 20 → окна начинаются со слов 0 и 20 + assert len(frags) == 2 + words0 = frags[0]["text"].split() + words1 = frags[1]["text"].split() + assert len(words0) == 25 and len(words1) == 25 + # перекрытие ровно 5 слов: хвост первого == голова второго + assert words0[-5:] == words1[:5] + + +def test_char_positions_are_offsets_into_source_text(): + text = _text(30) + f = split_into_fragments(text, window=25, overlap=5)[0] + assert text[f["start"]:].startswith("w000") # start — символьный офсет начала + assert text[: f["end"]].endswith("w024") # end — конец 25-го слова окна