test: юнит-суит L1/L2/L3 + ГОСТ (41 тест) и гейт в CI перед деплоем
Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились руками. Покрыта чистая логика детекции и форматирования (без БД/Redis/GPU/Ollama): - worker-indexer: L1 Winnowing (точные совпадения, идемпотентность отпечатка, диапазон signed int64) и L2 MinHash LSH (шинглы, Jaccard, near-duplicate + upsert через in-memory-фолбэк). - worker-gpu: L3 FAISS — возврат doc_id из PostgreSQL (IndexIDMap2), идемпотентность add_vectors (remove-before-add, без дублей), self-match ≈ 1, ранжирование. Прямо стережёт баги, из-за которых индекс переписывался. - worker-gost: ГОСТ 7.1-2003 и 7.0.5-2008 — авторы (≤3 / 4+ «и др.»/et al.), статья/книга/web, DOI, порядок сортировки кириллица→латиница, стр. в ссылке. Обвязка: per-service pytest.ini/conftest/requirements-test. scripts/run_tests.sh гоняет тесты в изолированных python:3.11-slim контейнерах (не засоряя хост), через Tsinghua-зеркало. CI: job `test` теперь гейтит `deploy` (needs: test) — падение тестов блокирует прод-деплой. make test / make test-one SVC=... Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,29 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: deploy
|
||||||
|
steps:
|
||||||
|
- name: Клонировать репозиторий (depth 1 — для тестов история не нужна)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="$(mktemp -d)"
|
||||||
|
echo "SRC=$SRC" >> "$GITHUB_ENV"
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
|
||||||
|
|
||||||
|
- name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$SRC"
|
||||||
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
|
- name: Убрать временный чекаут
|
||||||
|
if: always()
|
||||||
|
run: rm -rf "${SRC:-/tmp/none}"
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
needs: test # деплой только если юнит-тесты прошли
|
||||||
runs-on: deploy
|
runs-on: deploy
|
||||||
steps:
|
steps:
|
||||||
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
|
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
|
.PHONY: dev build migrate logs shell-api shell-gpu lint test test-one down ps restart clean test-up test-down test-logs test-ps
|
||||||
|
|
||||||
# ─── Переменные ────────────────────────────────────────────────────────────────
|
# ─── Переменные ────────────────────────────────────────────────────────────────
|
||||||
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
|
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
|
||||||
@@ -107,11 +107,14 @@ lint:
|
|||||||
lint-frontend:
|
lint-frontend:
|
||||||
cd services/frontend && npm run lint
|
cd services/frontend && npm run lint
|
||||||
|
|
||||||
|
# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах —
|
||||||
|
# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем.
|
||||||
test:
|
test:
|
||||||
$(COMPOSE_PROD) exec api pytest tests/ -v
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
test-cov:
|
# Тесты одного сервиса, напр.: make test-one SVC=worker-gost
|
||||||
$(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
|
test-one:
|
||||||
|
bash scripts/run_tests.sh $(SVC)
|
||||||
|
|
||||||
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
||||||
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
|
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
|
||||||
|
|||||||
67
scripts/run_tests.sh
Executable file
67
scripts/run_tests.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Прогон юнит-тестов всех сервисов в изолированных python:3.11-slim контейнерах.
|
||||||
|
#
|
||||||
|
# Тесты бьют по ЧИСТОЙ логике детекции и форматирования и не требуют внешней
|
||||||
|
# инфраструктуры (БД/Redis/RabbitMQ/GPU/Ollama) — поэтому гоняются одинаково на
|
||||||
|
# машине разработчика и в CI. Запуск в контейнере не засоряет хост зависимостями.
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# scripts/run_tests.sh # все сервисы
|
||||||
|
# scripts/run_tests.sh worker-gost # только один сервис
|
||||||
|
#
|
||||||
|
# PIP_INDEX_URL можно переопределить (по умолчанию — Tsinghua-зеркало, т.к.
|
||||||
|
# pypi.org из LAN недоступен).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
IMAGE="python:3.11-slim"
|
||||||
|
|
||||||
|
# service:apt-пакеты (нужны faiss-cpu → libgomp1)
|
||||||
|
SERVICES=(
|
||||||
|
"worker-indexer:"
|
||||||
|
"worker-gost:"
|
||||||
|
"worker-gpu:libgomp1"
|
||||||
|
)
|
||||||
|
|
||||||
|
run_service() {
|
||||||
|
local svc="$1" apt_pkgs="$2"
|
||||||
|
local dir="$ROOT/services/$svc"
|
||||||
|
if [[ ! -f "$dir/requirements-test.txt" ]]; then
|
||||||
|
echo "⚠ $svc: нет requirements-test.txt — пропуск"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
echo "▶ Тесты: $svc"
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$dir":/app -w /app \
|
||||||
|
-e PIP_INDEX_URL="$MIRROR" \
|
||||||
|
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
"$IMAGE" bash -c "
|
||||||
|
set -e
|
||||||
|
${apt_pkgs:+apt-get update -qq && apt-get install -y -qq --no-install-recommends $apt_pkgs >/dev/null && rm -rf /var/lib/apt/lists/*}
|
||||||
|
pip install --no-cache-dir --timeout 60 -q -r requirements-test.txt
|
||||||
|
python -m pytest
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
FILTER="${1:-}"
|
||||||
|
failed=0
|
||||||
|
for entry in "${SERVICES[@]}"; do
|
||||||
|
svc="${entry%%:*}"
|
||||||
|
apt="${entry#*:}"
|
||||||
|
[[ -n "$FILTER" && "$FILTER" != "$svc" ]] && continue
|
||||||
|
if ! run_service "$svc" "$apt"; then
|
||||||
|
failed=1
|
||||||
|
echo "✗ $svc: тесты упали"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
if [[ $failed -eq 0 ]]; then
|
||||||
|
echo "✅ Все юнит-тесты прошли"
|
||||||
|
else
|
||||||
|
echo "❌ Есть упавшие тесты"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
6
services/worker-gost/conftest.py
Normal file
6
services/worker-gost/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-gost/pytest.ini
Normal file
3
services/worker-gost/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
2
services/worker-gost/requirements-test.txt
Normal file
2
services/worker-gost/requirements-test.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика).
|
||||||
|
pytest==8.2.0
|
||||||
32
services/worker-gost/tests/test_gost_7_0_5.py
Normal file
32
services/worker-gost/tests/test_gost_7_0_5.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Юнит-тесты кратких библиографических ссылок по ГОСТ Р 7.0.5-2008."""
|
||||||
|
|
||||||
|
from app.formatters.gost_7_0_5 import GOST705Formatter, format_short
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_author_and_year():
|
||||||
|
assert format_short({"authors": [{"last_name": "Иванов"}], "year": 2023}) == "[Иванов, 2023]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_without_author_uses_title():
|
||||||
|
out = format_short({"title": "Большая книга про всё сразу", "year": 2020})
|
||||||
|
assert out == "[Большая книга про..., 2020]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_no_author_no_title():
|
||||||
|
assert format_short({"year": 2019}) == "[2019]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_missing_year_defaults():
|
||||||
|
assert format_short({"authors": [{"last_name": "Петров"}]}) == "[Петров, б. г.]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_with_page():
|
||||||
|
f = GOST705Formatter()
|
||||||
|
doc = {"authors": [{"last_name": "Иванов"}], "year": 2023}
|
||||||
|
assert f.format_inline(doc, page="15") == "[Иванов, 2023, с. 15]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_without_page():
|
||||||
|
f = GOST705Formatter()
|
||||||
|
doc = {"authors": [{"last_name": "Иванов"}], "year": 2023}
|
||||||
|
assert f.format_inline(doc) == "[Иванов, 2023]"
|
||||||
80
services/worker-gost/tests/test_gost_7_1.py
Normal file
80
services/worker-gost/tests/test_gost_7_1.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Юнит-тесты полного библиографического описания по ГОСТ 7.1-2003."""
|
||||||
|
|
||||||
|
from app.formatters.gost_7_1 import (
|
||||||
|
GOST71Formatter,
|
||||||
|
_format_author,
|
||||||
|
_format_authors,
|
||||||
|
_get_sort_key,
|
||||||
|
format_full,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_with_initials():
|
||||||
|
assert _format_author({"last_name": "Иванов", "initials": "И. И."}) == "Иванов И. И."
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_initials_get_trailing_dot():
|
||||||
|
assert _format_author({"last_name": "Иванов", "initials": "И. И"}) == "Иванов И. И."
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_from_first_name():
|
||||||
|
assert (
|
||||||
|
_format_author({"last_name": "Петров", "first_name": "Пётр Петрович"})
|
||||||
|
== "Петров П.П."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_last_name_only():
|
||||||
|
assert _format_author({"last_name": "Сидоров"}) == "Сидоров"
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_empty():
|
||||||
|
assert _format_author({}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_up_to_three_all_listed():
|
||||||
|
authors = [{"last_name": "А"}, {"last_name": "Б"}, {"last_name": "В"}]
|
||||||
|
assert _format_authors(authors) == "А, Б, В"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_four_plus_truncated_ru():
|
||||||
|
authors = [{"last_name": n} for n in ("А", "Б", "В", "Г")]
|
||||||
|
assert _format_authors(authors, lang="ru") == "А, Б, В, и др."
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_four_plus_truncated_en():
|
||||||
|
authors = [{"last_name": n} for n in ("A", "B", "C", "D")]
|
||||||
|
assert _format_authors(authors, lang="en") == "A, B, C, et al."
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_has_journal_year_and_doi():
|
||||||
|
doc = {
|
||||||
|
"title": "Заголовок статьи",
|
||||||
|
"journal": "Вестник науки",
|
||||||
|
"year": 2023,
|
||||||
|
"volume": "5",
|
||||||
|
"issue": "2",
|
||||||
|
"pages": "10-20",
|
||||||
|
"doi": "10.1234/abc",
|
||||||
|
"authors": [{"last_name": "Иванов", "initials": "И.И."}],
|
||||||
|
}
|
||||||
|
out = format_full(doc)
|
||||||
|
assert out.startswith("Иванов И.И.. Заголовок статьи")
|
||||||
|
assert "// Вестник науки" in out
|
||||||
|
assert "— 2023" in out
|
||||||
|
assert "Т. 5" in out and "№ 2" in out
|
||||||
|
assert "С. 10-20" in out
|
||||||
|
assert out.endswith("DOI: 10.1234/abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_resource_marked_and_has_url():
|
||||||
|
doc = {"title": "Портал", "url": "https://example.org", "year": 2024}
|
||||||
|
out = GOST71Formatter().format_full(doc)
|
||||||
|
assert "[Электронный ресурс]" in out
|
||||||
|
assert "URL: https://example.org" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_key_cyrillic_before_latin():
|
||||||
|
ru = _get_sort_key({"authors": [{"last_name": "Яковлев"}]})
|
||||||
|
en = _get_sort_key({"authors": [{"last_name": "Adams"}]})
|
||||||
|
assert ru < en # кириллица (префикс 0) сортируется раньше латиницы (префикс 1)
|
||||||
6
services/worker-gpu/conftest.py
Normal file
6
services/worker-gpu/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-gpu/pytest.ini
Normal file
3
services/worker-gpu/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
6
services/worker-gpu/requirements-test.txt
Normal file
6
services/worker-gpu/requirements-test.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-gpu (FAISS-индекс L3, в памяти, без GPU).
|
||||||
|
# torch/sentence-transformers НЕ нужны — тестируется только логика индекса.
|
||||||
|
pytest==8.2.0
|
||||||
|
faiss-cpu==1.8.0
|
||||||
|
numpy==1.26.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
88
services/worker-gpu/tests/test_faiss_manager.py
Normal file
88
services/worker-gpu/tests/test_faiss_manager.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Юнит-тесты FAISSManager (уровень 3 — семантический индекс).
|
||||||
|
|
||||||
|
Проверяют то, что было сломано в старой реализации и переписано:
|
||||||
|
- возврат doc_id из PostgreSQL (IndexIDMap2), а не позиционного номера;
|
||||||
|
- идемпотентность add_vectors по doc_id (remove-before-add, без дублей);
|
||||||
|
- корректность self-match (косинус ≈ 1) и ранжирования.
|
||||||
|
|
||||||
|
Работают целиком в памяти — диск (FAISS_INDEX_PATH) не трогается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("faiss")
|
||||||
|
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
from app.faiss_manager import FAISSManager # noqa: E402
|
||||||
|
|
||||||
|
DIM = settings.EMBED_DIM
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(vecs: np.ndarray) -> np.ndarray:
|
||||||
|
"""Нормировать строки к единичной длине (для косинуса через inner product)."""
|
||||||
|
vecs = np.asarray(vecs, dtype=np.float32)
|
||||||
|
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1.0
|
||||||
|
return vecs / norms
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_index():
|
||||||
|
"""Свежий пустой индекс в памяти на каждый тест; диск не читаем и не пишем."""
|
||||||
|
FAISSManager._index = FAISSManager._new_index()
|
||||||
|
yield
|
||||||
|
FAISSManager._index = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_search_returns_nothing():
|
||||||
|
assert FAISSManager.search(np.zeros(DIM, dtype=np.float32)) == []
|
||||||
|
assert FAISSManager.total_vectors() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_self_search_returns_postgres_doc_id():
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
vecs = _unit(rng.standard_normal((3, DIM)))
|
||||||
|
FAISSManager.add_vectors(vecs, [101, 202, 303])
|
||||||
|
assert FAISSManager.total_vectors() == 3
|
||||||
|
|
||||||
|
results = FAISSManager.search(vecs[1], k=1)
|
||||||
|
assert results, "поиск ничего не вернул"
|
||||||
|
top_id, score = results[0]
|
||||||
|
assert top_id == 202 # IndexIDMap2 возвращает doc_id, а не позицию
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-4) # self-match: косинус ≈ 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_is_idempotent_by_doc_id():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2])
|
||||||
|
|
||||||
|
# Повторный эмбеддинг тех же id не должен плодить дубли (remove-before-add)
|
||||||
|
updated = _unit(rng.standard_normal((2, DIM)))
|
||||||
|
FAISSManager.add_vectors(updated, [1, 2])
|
||||||
|
assert FAISSManager.total_vectors() == 2
|
||||||
|
|
||||||
|
# ...и поиск возвращает ОБНОВЛЁННЫЙ вектор
|
||||||
|
top_id, score = FAISSManager.search(updated[0], k=1)[0]
|
||||||
|
assert top_id == 1
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_ranks_nearest_first():
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
v = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
near = _unit((v + 0.01 * rng.standard_normal(DIM))[None, :])[0]
|
||||||
|
far = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
FAISSManager.add_vectors(np.stack([near, far]), [10, 20])
|
||||||
|
|
||||||
|
results = FAISSManager.search(v, k=2)
|
||||||
|
assert [doc_id for doc_id, _ in results][0] == 10 # ближайший — near
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_respects_k_and_index_size():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [5, 6])
|
||||||
|
# k больше числа векторов не должно приводить к падению или id == -1
|
||||||
|
results = FAISSManager.search(_unit(rng.standard_normal((1, DIM)))[0], k=100)
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(doc_id in (5, 6) for doc_id, _ in results)
|
||||||
6
services/worker-indexer/conftest.py
Normal file
6
services/worker-indexer/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-indexer/pytest.ini
Normal file
3
services/worker-indexer/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
8
services/worker-indexer/requirements-test.txt
Normal file
8
services/worker-indexer/requirements-test.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-indexer (чистая логика L1/L2, без инфры).
|
||||||
|
# Redis намеренно НЕ ставим — тогда MinHash LSH детерминированно уходит
|
||||||
|
# в in-memory-фолбэк, что и нужно тестам.
|
||||||
|
pytest==8.2.0
|
||||||
|
xxhash==3.4.1
|
||||||
|
datasketch==1.6.5
|
||||||
|
numpy==1.26.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
62
services/worker-indexer/tests/test_minhash.py
Normal file
62
services/worker-indexer/tests/test_minhash.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Юнит-тесты MinHash LSH (уровень 2 — нечёткие совпадения).
|
||||||
|
|
||||||
|
Redis в тестовом окружении недоступен → get_lsh() детерминированно падает в
|
||||||
|
in-memory-фолбэк, что и проверяется здесь.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.algorithms import minhash
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_shingles_basic():
|
||||||
|
assert minhash.get_shingles("один два три четыре", k=3) == {
|
||||||
|
"один два три",
|
||||||
|
"два три четыре",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_shingles_short_text():
|
||||||
|
assert minhash.get_shingles("одно", k=3) == {"одно"}
|
||||||
|
assert minhash.get_shingles("", k=3) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_text_jaccard_is_one():
|
||||||
|
t = "нейронные сети глубокого обучения распознают образы на изображениях точно"
|
||||||
|
assert minhash.compute_jaccard_minhash(t, t) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_text_jaccard_near_zero():
|
||||||
|
a = "квантовая физика элементарных частиц и теория поля"
|
||||||
|
b = "кулинарные рецепты домашней выпечки пшеничного хлеба"
|
||||||
|
assert minhash.compute_jaccard_minhash(a, b) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_lsh_finds_near_duplicate_and_ignores_unrelated():
|
||||||
|
minhash.reset_lsh()
|
||||||
|
try:
|
||||||
|
base = (
|
||||||
|
"нейронные сети глубокого обучения применяются для распознавания "
|
||||||
|
"образов на цифровых изображениях и в задачах компьютерного зрения"
|
||||||
|
)
|
||||||
|
minhash.add_to_lsh("doc:1", base)
|
||||||
|
|
||||||
|
near = base.replace("изображениях", "фотографиях")
|
||||||
|
assert "doc:1" in minhash.find_similar(near)
|
||||||
|
|
||||||
|
unrelated = (
|
||||||
|
"экономический анализ рынка недвижимости в крупных городах страны "
|
||||||
|
"за последние несколько отчётных финансовых кварталов подряд"
|
||||||
|
)
|
||||||
|
assert "doc:1" not in minhash.find_similar(unrelated)
|
||||||
|
finally:
|
||||||
|
minhash.reset_lsh()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lsh_upsert_does_not_duplicate():
|
||||||
|
minhash.reset_lsh()
|
||||||
|
try:
|
||||||
|
text = "обработка естественного языка методами машинного обучения и статистики"
|
||||||
|
minhash.add_to_lsh("doc:9", text)
|
||||||
|
minhash.add_to_lsh("doc:9", text) # повторно тот же ключ — upsert, не дубль
|
||||||
|
assert minhash.find_similar(text).count("doc:9") == 1
|
||||||
|
finally:
|
||||||
|
minhash.reset_lsh()
|
||||||
79
services/worker-indexer/tests/test_winnowing.py
Normal file
79
services/worker-indexer/tests/test_winnowing.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Юнит-тесты алгоритма Winnowing (уровень 1 — точные/частичные совпадения)."""
|
||||||
|
|
||||||
|
from app.algorithms.winnowing import (
|
||||||
|
compute_similarity,
|
||||||
|
get_ngrams,
|
||||||
|
hash_ngram,
|
||||||
|
jaccard_similarity,
|
||||||
|
winnow,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
|
||||||
|
LONG = (
|
||||||
|
"машинное обучение позволяет извлекать закономерности из больших объёмов "
|
||||||
|
"данных без явного программирования каждого правила вручную аналитиком"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ngrams_basic():
|
||||||
|
assert get_ngrams(["a", "b", "c", "d"], k=2) == ["a b", "b c", "c d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ngrams_too_short_is_empty():
|
||||||
|
assert get_ngrams(["a", "b"], k=5) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_ngram_is_deterministic():
|
||||||
|
assert hash_ngram("одна и та же строка") == hash_ngram("одна и та же строка")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_ngram_fits_signed_int64():
|
||||||
|
# Хэш обязан умещаться в PostgreSQL BIGINT (signed 64-bit)
|
||||||
|
for s in ("a", "тест", "one two three four five", "𝔘𝔫𝔦𝔠𝔬𝔡𝔢"):
|
||||||
|
h = hash_ngram(s)
|
||||||
|
assert -(2**63) <= h <= 2**63 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_short_text_is_empty():
|
||||||
|
assert winnow("три слова тут", k=5) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_identical_text_identical_fingerprint():
|
||||||
|
assert winnow(LONG) == winnow(LONG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_is_case_insensitive():
|
||||||
|
assert winnow(LONG) == winnow(LONG.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_identical_is_one():
|
||||||
|
fp = winnow(LONG)
|
||||||
|
assert fp # непустой отпечаток
|
||||||
|
assert jaccard_similarity(fp, fp) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_disjoint_is_zero():
|
||||||
|
assert jaccard_similarity({1, 2, 3}, {4, 5, 6}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_empty_is_zero():
|
||||||
|
assert jaccard_similarity(set(), {1, 2}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_identical_documents_is_one():
|
||||||
|
# Ключевая гарантия L1: копия документа детектится как 100% совпадение
|
||||||
|
assert compute_similarity(LONG, LONG) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_unrelated_documents_is_low():
|
||||||
|
other = (
|
||||||
|
"рецепт домашнего хлеба на закваске требует терпения муки воды соли "
|
||||||
|
"и тёплого места для медленного подъёма теста в течение ночи"
|
||||||
|
)
|
||||||
|
assert compute_similarity(LONG, other) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_partial_overlap_is_between():
|
||||||
|
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
|
||||||
|
sim = compute_similarity(LONG, modified)
|
||||||
|
assert 0.0 < sim < 1.0
|
||||||
Reference in New Issue
Block a user