145 lines
6.0 KiB
Python
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
|