"""이미 반영된 리뷰 항목의 회귀 방지 (#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