o2o-plagiarism-ai/tests/test_review_regressions.py

145 lines
6.0 KiB
Python

"""이미 반영된 리뷰 항목의 회귀 방지 (#2 #4 #6 #7 #8)."""
from __future__ import annotations
import json
import pytest
from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord
pytest.importorskip("scipy", reason="scipy 미설치")
from app.engine.persistent_index import ( # noqa: E402
VECTORIZER_CONFIG,
PersistentCorpusIndex,
)
def _store_with(tmp_path, texts: list[str]) -> CorpusStore:
store = CorpusStore(tmp_path / "corpus.sqlite3")
store.upsert_document(DocumentRecord(document_id="doc-1", title=""))
store.add_segments([
SegmentRecord(segment_id=f"seg-{i}", document_id="doc-1", text=t,
ordinal=str(i), char_start=0, char_end=len(t))
for i, t in enumerate(texts)
])
return store
# --- #2 원자적 교체 -------------------------------------------------------
def test_matrix_file_is_generation_scoped(tmp_path):
"""세대별 파일명이라 교체 중 이전 인덱스가 덮어써지지 않는다."""
store = _store_with(tmp_path, ["첫 번째 세그먼트 본문입니다."])
index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index")
index.sync()
first = json.loads((tmp_path / "index" / "index.json").read_text())["matrix_file"]
store.add_segments([SegmentRecord(
segment_id="seg-9", document_id="doc-1", text="두 번째 세그먼트 본문입니다.",
ordinal="9", char_start=0, char_end=10,
)])
index.sync()
second = json.loads((tmp_path / "index" / "index.json").read_text())["matrix_file"]
assert first != second, "세대가 바뀌면 파일명도 바뀌어야 한다"
assert (tmp_path / "index" / first).exists(), "이전 세대 파일이 살아 있어야 한다"
def test_replaced_index_object_is_self_consistent(tmp_path):
"""load() 한 객체는 matrix 행수와 segment_ids 길이가 항상 일치한다."""
_store_with(tmp_path, [f"세그먼트 {i} 본문입니다. 파도 소리." for i in range(5)])
index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index")
index.sync()
fresh = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load()
assert fresh._matrix.shape[0] == len(fresh._meta["segment_ids"]) == 5
# --- #8 vectorizer 설정 고정 ---------------------------------------------
def test_index_with_different_vectorizer_config_is_rejected(tmp_path):
_store_with(tmp_path, ["본문입니다. 파도 소리를 들었다."])
index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index")
index.sync()
meta_path = tmp_path / "index" / "index.json"
meta = json.loads(meta_path.read_text())
meta["vectorizer_config"] = {**VECTORIZER_CONFIG, "ngram_range": [2, 6]}
meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
with pytest.raises(ValueError, match="재빌드"):
PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load()
def test_config_change_forces_rebuild_not_append(tmp_path):
_store_with(tmp_path, ["본문입니다. 파도 소리를 들었다."])
index_dir = tmp_path / "index"
index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", index_dir)
index.sync()
# n_features 가 달라지면 append 가 성립하지 않아야 한다
result = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", index_dir).sync(n_features=2**16)
assert result["mode"] == "rebuild"
# --- #6 document_count 캐시 ----------------------------------------------
def test_document_count_served_from_meta_without_table_scan(tmp_path, monkeypatch):
_store_with(tmp_path, ["본문입니다."])
index = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index")
index.sync()
loaded = PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").load()
def explode():
raise AssertionError("document_count 가 매 요청 DB 를 스캔하고 있다")
monkeypatch.setattr(loaded.store, "document_count", explode)
assert loaded.document_count == 1
# --- #7 API 업로드 청킹 ---------------------------------------------------
def test_api_upload_is_chunked_not_single_segment(tmp_path, monkeypatch):
from app.core.config import Settings
from app.engine.detector import PlagiarismDetector
settings = Settings(
use_persistent_index=True,
corpus_db_path=str(tmp_path / "corpus.sqlite3"),
persistent_index_dir=str(tmp_path / "index"),
precedents_path=str(tmp_path / "none.jsonl"),
ai_detector_model_path=str(tmp_path / "none.joblib"),
use_clustering=False,
use_lsh_filter=False,
)
_store_with(tmp_path, ["씨앗 세그먼트입니다."])
PersistentCorpusIndex(tmp_path / "corpus.sqlite3", tmp_path / "index").sync()
detector = PlagiarismDetector(settings=settings)
assert detector.uses_persistent_index
long_text = "긴 원고 문장입니다. 계속 이어집니다. " * 200 # 약 4,000자
doc_id = detector.add_persistent_document(None, "업로드 원고", long_text)
segments = [s for s in detector._persistent.store.iter_segments()
if s.document_id == doc_id]
assert len(segments) > 1, "문서 전체가 세그먼트 1개로 저장되면 부분 표절을 못 잡는다"
assert all(len(s.text) <= 1000 for s in segments)
# 오프셋이 원문을 정확히 복원해야 한다
for seg in segments:
assert long_text.strip()[seg.char_start:seg.char_end] == seg.text
# --- #4 detect 가 이벤트 루프를 막지 않는지 -------------------------------
def test_detect_route_runs_in_threadpool():
"""라우트가 동기 detect 를 threadpool 로 넘기는지 (소스 계약 확인)."""
import inspect
from app.api import routes
source = inspect.getsource(routes.detect)
assert "run_in_threadpool" in source
for name in ("corpus_upload_json", "corpus_upload_file", "corpus_delete"):
assert "run_in_threadpool" in inspect.getsource(getattr(routes, name)), name