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
|
||||
|
||||
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:
|
||||
needs: test # деплой только если юнит-тесты прошли
|
||||
runs-on: deploy
|
||||
steps:
|
||||
- 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, остальная инфра
|
||||
@@ -107,11 +107,14 @@ lint:
|
||||
lint-frontend:
|
||||
cd services/frontend && npm run lint
|
||||
|
||||
# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах —
|
||||
# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем.
|
||||
test:
|
||||
$(COMPOSE_PROD) exec api pytest tests/ -v
|
||||
bash scripts/run_tests.sh
|
||||
|
||||
test-cov:
|
||||
$(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
|
||||
# Тесты одного сервиса, напр.: make test-one SVC=worker-gost
|
||||
test-one:
|
||||
bash scripts/run_tests.sh $(SVC)
|
||||
|
||||
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
# ВНИМАНИЕ: -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