fix(protected-phrases): isolate phrase generation per book
Run full-book candidate generation inside worker-owned sessions so each book commits independently during backfills. Abort recalculation when a book has no indexed chapters to preserve existing phrase data, and update admin/UI tests for the new generation flow.
This commit is contained in:
@@ -2,15 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from python.ebook_search.config import EbookSearchConfig
|
||||
from python.ebook_search.protected_phrases import generate_ngrams
|
||||
from python.ebook_search.protected_phrases.config import (
|
||||
get_bad_ends,
|
||||
get_most_common_words,
|
||||
@@ -55,25 +56,42 @@ from python.orm.richie import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine() -> AsyncGenerator[AsyncEngine]:
|
||||
"""Create a shared in-memory async database engine for phrase tests."""
|
||||
test_engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async def engine(tmp_path: Path) -> AsyncGenerator[AsyncEngine]:
|
||||
"""Create a file-backed async database engine that worker threads can also reach."""
|
||||
test_engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'phrases.db'}")
|
||||
async with test_engine.begin() as connection:
|
||||
await connection.run_sync(RichieBase.metadata.create_all)
|
||||
yield test_engine
|
||||
await test_engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_pool(engine: AsyncEngine, mocker: MockerFixture) -> Generator[ThreadPoolExecutor]:
|
||||
"""Run pooled candidate generation in threads against the test database.
|
||||
|
||||
Spawned worker processes can see neither the test database nor test patches, so the shared
|
||||
extraction pool is replaced with a thread pool and worker engines are built for the test
|
||||
database instead of from Postgres environment variables.
|
||||
"""
|
||||
thread_pool = ThreadPoolExecutor(max_workers=1)
|
||||
database_url = engine.url.render_as_string(hide_password=False)
|
||||
mocker.patch.object(generate_ngrams, "get_extraction_pool", return_value=thread_pool)
|
||||
mocker.patch.object(
|
||||
generate_ngrams,
|
||||
"get_async_postgres_engine",
|
||||
side_effect=lambda **_kwargs: create_async_engine(database_url),
|
||||
)
|
||||
yield thread_pool
|
||||
thread_pool.shutdown(wait=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession]:
|
||||
"""Provide a session on the shared in-memory database."""
|
||||
@@ -271,7 +289,9 @@ async def test_index_chunk_phrase_mentions_uses_normalized_window_lookup(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_generate_candidate_phrases_for_books_stores_unjudged_candidates(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
config: EbookSearchConfig,
|
||||
) -> None:
|
||||
@@ -300,8 +320,7 @@ async def test_generate_candidate_phrases_for_books_stores_unjudged_candidates(
|
||||
}
|
||||
)
|
||||
|
||||
result = await generate_candidate_phrases_for_books(session, build_config)
|
||||
await session.commit()
|
||||
result = await generate_candidate_phrases_for_books(engine, build_config)
|
||||
|
||||
candidate = await session.scalar(select(EbookCandidatePhrase))
|
||||
assert result.books_seen == 1
|
||||
@@ -313,7 +332,9 @@ async def test_generate_candidate_phrases_for_books_stores_unjudged_candidates(
|
||||
assert await session.scalar(select(EbookProtectedPhrase)) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_generate_candidate_phrases_for_books_filters_one_token_and_one_use_candidates(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
config: EbookSearchConfig,
|
||||
) -> None:
|
||||
@@ -377,8 +398,7 @@ async def test_generate_candidate_phrases_for_books_filters_one_token_and_one_us
|
||||
}
|
||||
)
|
||||
|
||||
result = await generate_candidate_phrases_for_books(session, build_config)
|
||||
await session.commit()
|
||||
result = await generate_candidate_phrases_for_books(engine, build_config)
|
||||
|
||||
candidates = list(await session.scalars(select(EbookCandidatePhrase)))
|
||||
phrase_norms = {candidate.phrase_norm for candidate in candidates}
|
||||
@@ -394,7 +414,9 @@ async def test_generate_candidate_phrases_for_books_filters_one_token_and_one_us
|
||||
assert all(not all(token in common_words for token in candidate.phrase_norm.split()) for candidate in candidates)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_generate_candidate_phrases_for_books_commits_after_each_book(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
config: EbookSearchConfig,
|
||||
mocker: MockerFixture,
|
||||
@@ -431,7 +453,7 @@ async def test_generate_candidate_phrases_for_books_commits_after_each_book(
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
commit_spy = mocker.spy(session, "commit")
|
||||
commit_spy = mocker.spy(AsyncSession, "commit")
|
||||
build_config = config.model_copy(
|
||||
update={
|
||||
"protected_phrase_max_candidates_per_book": 1,
|
||||
@@ -440,13 +462,91 @@ async def test_generate_candidate_phrases_for_books_commits_after_each_book(
|
||||
}
|
||||
)
|
||||
|
||||
result = await generate_candidate_phrases_for_books(session, build_config)
|
||||
result = await generate_candidate_phrases_for_books(engine, build_config)
|
||||
|
||||
assert result.books_built == 2
|
||||
assert result.candidate_phrases == 2
|
||||
assert commit_spy.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_generate_candidate_phrases_for_books_failure_keeps_committed_books(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
config: EbookSearchConfig,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A failing book should be logged and skipped while the other books stay committed."""
|
||||
first = await add_source(session)
|
||||
second = await add_source(session, file_path="/library/book-2.epub", file_sha256="z" * 64)
|
||||
session.add_all(
|
||||
[
|
||||
EbookChunk(
|
||||
id=1,
|
||||
source_id=first.id,
|
||||
chapter_id=None,
|
||||
chunk_index=0,
|
||||
text="lock in lock in lock in",
|
||||
token_start=0,
|
||||
token_count=6,
|
||||
page_label=None,
|
||||
content_sha256="f" * 64,
|
||||
search_text="lock in lock in lock in",
|
||||
),
|
||||
EbookChunk(
|
||||
id=2,
|
||||
source_id=second.id,
|
||||
chapter_id=None,
|
||||
chunk_index=0,
|
||||
text="mage king mage king mage king",
|
||||
token_start=0,
|
||||
token_count=6,
|
||||
page_label=None,
|
||||
content_sha256="g" * 64,
|
||||
search_text="mage king mage king mage king",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
build_config = config.model_copy(
|
||||
update={
|
||||
"protected_phrase_max_candidates_per_book": 1,
|
||||
"phrase_min_tokens": 2,
|
||||
"phrase_max_tokens": 2,
|
||||
}
|
||||
)
|
||||
real_store = generate_ngrams.store_candidate_phrases_for_book
|
||||
first_book_id = first.id
|
||||
second_book_id = second.id
|
||||
|
||||
async def store_failing_second_book(
|
||||
store_session: AsyncSession,
|
||||
book_id: int,
|
||||
series_id: int | None,
|
||||
limited_candidates: list[PhraseCandidate],
|
||||
store_config: EbookSearchConfig,
|
||||
*,
|
||||
replace_all: bool = False,
|
||||
) -> int:
|
||||
if book_id == second_book_id:
|
||||
message = "storage exploded"
|
||||
raise RuntimeError(message)
|
||||
return await real_store(
|
||||
store_session, book_id, series_id, limited_candidates, store_config, replace_all=replace_all
|
||||
)
|
||||
|
||||
mocker.patch.object(generate_ngrams, "store_candidate_phrases_for_book", side_effect=store_failing_second_book)
|
||||
|
||||
result = await generate_candidate_phrases_for_books(engine, build_config)
|
||||
|
||||
stored_book_ids = set((await session.scalars(select(EbookCandidatePhrase.book_id))).all())
|
||||
assert stored_book_ids == {first_book_id}
|
||||
assert result.books_seen == 2
|
||||
assert result.books_built == 1
|
||||
assert result.candidate_phrases == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_judge_candidate_phrases_for_books_promotes_stored_candidates(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
@@ -480,8 +580,7 @@ async def test_judge_candidate_phrases_for_books_promotes_stored_candidates(
|
||||
"phrase_judge_phrase_workers": 1,
|
||||
}
|
||||
)
|
||||
await generate_candidate_phrases_for_books(session, build_config)
|
||||
await session.commit()
|
||||
await generate_candidate_phrases_for_books(engine, build_config)
|
||||
mocker.patch(
|
||||
"python.ebook_search.protected_phrases.judge_ngrams.judge_candidate_async",
|
||||
return_value=LLMJudgment(
|
||||
@@ -513,6 +612,7 @@ async def test_judge_candidate_phrases_for_books_promotes_stored_candidates(
|
||||
assert mention.phrase_id == phrase.id
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("worker_pool")
|
||||
async def test_judge_candidate_phrases_for_books_logs_and_continues_after_book_failure(
|
||||
engine: AsyncEngine,
|
||||
session: AsyncSession,
|
||||
@@ -561,8 +661,7 @@ async def test_judge_candidate_phrases_for_books_logs_and_continues_after_book_f
|
||||
"phrase_judge_phrase_workers": 1,
|
||||
}
|
||||
)
|
||||
await generate_candidate_phrases_for_books(session, build_config)
|
||||
await session.commit()
|
||||
await generate_candidate_phrases_for_books(engine, build_config)
|
||||
|
||||
def judge_or_fail(_client: object, _config: EbookSearchConfig, candidate: PhraseCandidate) -> LLMJudgment:
|
||||
if candidate.phrase_norm == "lock in":
|
||||
@@ -805,6 +904,7 @@ async def test_recalculate_candidate_phrases_for_book_removes_old_phrase_data(
|
||||
|
||||
result = await recalculate_candidate_phrases_for_book(session, source, build_config)
|
||||
|
||||
session.expire_all()
|
||||
candidates = list(await session.scalars(select(EbookCandidatePhrase)))
|
||||
assert result.deleted_candidates == 1
|
||||
assert result.deleted_protected_phrases == 1
|
||||
@@ -817,6 +917,38 @@ async def test_recalculate_candidate_phrases_for_book_removes_old_phrase_data(
|
||||
assert await session.scalar(select(EbookChunkPhraseMention)) is None
|
||||
|
||||
|
||||
async def test_recalculate_candidate_phrases_for_book_aborts_without_chapters(
|
||||
session: AsyncSession,
|
||||
config: EbookSearchConfig,
|
||||
) -> None:
|
||||
"""Recalculating a book with no indexed chapters should raise and leave phrase data intact."""
|
||||
source = await add_source(session)
|
||||
existing_candidate = EbookCandidatePhrase(
|
||||
book_id=source.id,
|
||||
series_id=None,
|
||||
phrase_text="old phrase",
|
||||
phrase_norm="old phrase",
|
||||
token_count=2,
|
||||
source_raw_ngram=True,
|
||||
raw_count=1,
|
||||
chapter_count=1,
|
||||
candidate_score=1.0,
|
||||
llm_judged=False,
|
||||
)
|
||||
session.add(existing_candidate)
|
||||
await session.flush()
|
||||
existing_phrase = await add_phrase(session, source.id, phrase_text="old phrase", phrase_norm="old phrase")
|
||||
await session.commit()
|
||||
existing_candidate_id = existing_candidate.id
|
||||
existing_phrase_id = existing_phrase.id
|
||||
|
||||
with pytest.raises(ValueError, match="no indexed chapters"):
|
||||
await recalculate_candidate_phrases_for_book(session, source, config)
|
||||
|
||||
assert await session.scalar(select(EbookCandidatePhrase.id)) == existing_candidate_id
|
||||
assert await session.scalar(select(EbookProtectedPhrase.id)) == existing_phrase_id
|
||||
|
||||
|
||||
async def test_corpus_phrase_stats_counts_phrases_and_book_coverage(session: AsyncSession) -> None:
|
||||
"""Corpus stats should count phrases plus how many books are generated and fully judged."""
|
||||
unjudged_book = await add_source(session)
|
||||
|
||||
@@ -567,33 +567,8 @@ def test_admin_page_shows_protected_phrase_stats(mocker: MockerFixture) -> None:
|
||||
assert value in response.text
|
||||
|
||||
|
||||
def test_ui_add_missing_phrases_generates_only_missing_books(mocker: MockerFixture) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_generate(_session, _config, *, only_missing=False):
|
||||
captured["only_missing"] = only_missing
|
||||
return PhraseCandidateGenerationResult(books_seen=3, books_built=2, candidate_phrases=42)
|
||||
|
||||
mocker.patch(
|
||||
"python.ebook_search.api.routes.admin.generate_candidate_phrases_for_books",
|
||||
side_effect=fake_generate,
|
||||
)
|
||||
patch_app_runtime(mocker)
|
||||
app = create_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/admin/phrases/generate-missing")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["only_missing"] is True
|
||||
assert "42 candidates stored" in response.text
|
||||
|
||||
|
||||
def test_ui_regenerate_all_phrases_generates_every_book(mocker: MockerFixture) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_generate(_session, _config, *, only_missing=False):
|
||||
captured["only_missing"] = only_missing
|
||||
def fake_generate(_session, _config):
|
||||
return PhraseCandidateGenerationResult(books_seen=5, books_built=5, candidate_phrases=99)
|
||||
|
||||
mocker.patch(
|
||||
@@ -607,7 +582,6 @@ def test_ui_regenerate_all_phrases_generates_every_book(mocker: MockerFixture) -
|
||||
response = client.post("/admin/phrases/generate-all")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["only_missing"] is False
|
||||
assert "5 of 5 books" in response.text
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user