refactor(indexer): вынести разбиение на фрагменты в app.fragments + 5 тестов
_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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ docker run --rm \
|
|||||||
ruff check services/ scripts/
|
ruff check services/ scripts/
|
||||||
|
|
||||||
echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)'
|
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-gost && mypy --config-file /repo/mypy.ini app/formatters/ )
|
||||||
( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py )
|
( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py )
|
||||||
"
|
"
|
||||||
|
|||||||
56
services/worker-indexer/app/fragments.py
Normal file
56
services/worker-indexer/app/fragments.py
Normal file
@@ -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
|
||||||
@@ -16,60 +16,11 @@ from app.config import settings
|
|||||||
from app.db import db_session, get_minio, update_task_status
|
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.docx import extract_text_from_docx, extract_text_from_txt
|
||||||
from app.extractors.pdf import extract_text_from_pdf
|
from app.extractors.pdf import extract_text_from_pdf
|
||||||
|
from app.fragments import split_into_fragments
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
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(
|
@celery_app.task(
|
||||||
name="index.extract_and_check",
|
name="index.extract_and_check",
|
||||||
bind=True,
|
bind=True,
|
||||||
@@ -133,7 +84,7 @@ def extract_and_check(
|
|||||||
_stage_work(task_id, minio_key, filename, text, word_count)
|
_stage_work(task_id, minio_key, filename, text, word_count)
|
||||||
|
|
||||||
# Разбить на фрагменты
|
# Разбить на фрагменты
|
||||||
fragments = _split_into_fragments(
|
fragments = split_into_fragments(
|
||||||
text,
|
text,
|
||||||
window=settings.FRAGMENT_WINDOW_WORDS,
|
window=settings.FRAGMENT_WINDOW_WORDS,
|
||||||
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
||||||
|
|||||||
43
services/worker-indexer/tests/test_fragments.py
Normal file
43
services/worker-indexer/tests/test_fragments.py
Normal file
@@ -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-го слова окна
|
||||||
Reference in New Issue
Block a user