refactor(ebook-search): simplify search and phrase matching

This commit is contained in:
Richie committed 2026-07-24 11:38:51 -04:00
1 parent 0028237579
commit e010756e09
16 files changed
+490 -557

No files matched your search

+89 -23
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING
import pytest
from sqlalchemy import select
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from python.ebook_search.config import EbookSearchConfig
@@ -33,15 +33,12 @@ from python.ebook_search.protected_phrases.matching import (
detect_protected_phrases_for_query,
index_chunk_phrase_mentions,
load_phrase_lookup,
phrase_hit_counts_for_chunks,
phrase_hits_for_chunks,
resolve_overlaps,
)
from python.ebook_search.protected_phrases.models import (
ChunkPhraseHit,
HydratedPhraseMatch,
LLMJudgment,
PhraseCandidate,
PhraseMatch,
)
from python.ebook_search.protected_phrases.store import book_ids_pending_first_judgment, corpus_phrase_stats
from python.ebook_search.protected_phrases.text_normalization import normalize_text, tokenize
@@ -187,26 +184,89 @@ def test_score_candidate_weights_metadata_source(config: EbookSearchConfig) -> N
assert score_candidate(metadata_only, config) == 2.0 + 0.5
async def test_detect_protected_phrases_hydrates_alias_matches(
async def test_load_phrase_lookup_loads_phrases_and_aliases_with_one_query(
engine: AsyncEngine,
session: AsyncSession,
) -> None:
"""One joined query should load phrases with zero, one, or many aliases."""
source = await add_source(session)
aliased_phrase = await add_phrase(session, source.id, phrase_text="lock in", phrase_norm="lock in")
plain_phrase = await add_phrase(session, source.id, phrase_text="mage king", phrase_norm="mage king")
session.add_all(
[
EbookPhraseAlias(phrase_id=aliased_phrase.id, alias_text="locked in", alias_norm="locked in"),
EbookPhraseAlias(
phrase_id=aliased_phrase.id,
alias_text="locked completely in",
alias_norm="locked completely in",
),
]
)
await session.commit()
statements: list[str] = []
def record_statement(*args: object) -> None:
statements.append(str(args[2]))
event.listen(engine.sync_engine, "before_cursor_execute", record_statement)
try:
lookup = await load_phrase_lookup(session, EbookSearchConfig(phrase_max_tokens=2), book_id=source.id)
finally:
event.remove(engine.sync_engine, "before_cursor_execute", record_statement)
assert lookup.phrase_ids_by_norm == {
"lock in": (aliased_phrase.id,),
"locked completely in": (aliased_phrase.id,),
"locked in": (aliased_phrase.id,),
"mage king": (plain_phrase.id,),
}
assert lookup.phrases_by_id == {aliased_phrase.id: aliased_phrase, plain_phrase.id: plain_phrase}
assert lookup.max_tokens == 3
assert len(statements) == 1
async def test_detect_protected_phrases_queries_canonical_and_alias_matches_once(
engine: AsyncEngine,
session: AsyncSession,
config: EbookSearchConfig,
) -> None:
"""Query detection should use RAM aliases and hydrate phrase metadata from the DB."""
"""Query detection should hydrate repeated canonical and alias matches with one statement."""
source = await add_source(session)
phrase = await add_phrase(session, source.id, phrase_text="lock in", phrase_norm="lock in")
await add_phrase(session, source.id, phrase_text="mage king", phrase_norm="mage king")
session.add(EbookPhraseAlias(phrase_id=phrase.id, alias_text="locked in", alias_norm="locked in"))
await session.commit()
matches = await detect_protected_phrases_for_query(session, "what is locked-in", config)
statements: list[str] = []
assert [(match.phrase_text, match.canonical_id, match.phrase_type) for match in matches] == [
("lock in", "condition:lock_in", "fictional_condition")
def record_statement(*args: object) -> None:
statements.append(str(args[2]))
event.listen(engine.sync_engine, "before_cursor_execute", record_statement)
try:
matches = await detect_protected_phrases_for_query(
session,
"lock-in then locked-in and lock-in",
config,
)
empty_matches = await detect_protected_phrases_for_query(session, "", config)
finally:
event.remove(engine.sync_engine, "before_cursor_execute", record_statement)
assert [(match.phrase_text, match.matched_norm, match.start_token, match.end_token) for match in matches] == [
("lock in", "lock in", 0, 2),
("lock in", "locked in", 3, 5),
("lock in", "lock in", 6, 8),
]
assert empty_matches == []
assert len(statements) == 1
assert " UNION " in statements[0]
def test_resolve_overlaps_keeps_independent_nested_phrases() -> None:
"""Overlap resolution should keep useful nested concepts when metadata permits it."""
child = hydrated_match(
child = phrase_match(
phrase_id=1,
phrase_text="mage king",
canonical_id="title:mage_king",
@@ -214,7 +274,7 @@ def test_resolve_overlaps_keeps_independent_nested_phrases() -> None:
end_token=5,
allow_nested=True,
)
parent = hydrated_match(
parent = phrase_match(
phrase_id=2,
phrase_text="mage king of mars",
canonical_id="entity:mage_king_of_mars",
@@ -231,7 +291,7 @@ def test_resolve_overlaps_keeps_independent_nested_phrases() -> None:
def test_resolve_overlaps_suppresses_weaker_same_canonical_match() -> None:
"""Same-canonical overlaps should keep the stronger evidence."""
weak = hydrated_match(
weak = phrase_match(
phrase_id=1,
phrase_text="lock",
canonical_id="condition:lock_in",
@@ -239,7 +299,7 @@ def test_resolve_overlaps_suppresses_weaker_same_canonical_match() -> None:
end_token=3,
importance=0.2,
)
strong = hydrated_match(
strong = phrase_match(
phrase_id=2,
phrase_text="lock in",
canonical_id="condition:lock_in",
@@ -274,19 +334,25 @@ async def test_index_chunk_phrase_mentions_uses_normalized_window_lookup(
await session.commit()
lookup = await load_phrase_lookup(session, config, book_id=source.id)
count = await index_chunk_phrase_mentions(session, chunk, lookup=lookup)
statements: list[str] = []
def record_statement(*args: object) -> None:
statements.append(str(args[2]))
event.listen(session.bind.sync_engine, "before_cursor_execute", record_statement)
try:
count = index_chunk_phrase_mentions(session, chunk, lookup=lookup)
finally:
event.remove(session.bind.sync_engine, "before_cursor_execute", record_statement)
await session.commit()
assert statements == []
mention = await session.scalar(select(EbookChunkPhraseMention))
assert count == 1
assert mention is not None
assert mention.chunk_id == chunk.id
assert mention.phrase_id == phrase.id
assert chunk.text[mention.start_char : mention.end_char] == "lock-in"
assert await phrase_hit_counts_for_chunks(session, chunk_ids=[chunk.id], phrase_ids=[phrase.id]) == {chunk.id: 1}
assert await phrase_hits_for_chunks(session, chunk_ids=[chunk.id], phrase_ids=[phrase.id]) == {
chunk.id: (ChunkPhraseHit(phrase_id=phrase.id, phrase_text="lock in", mention_count=1),)
}
@pytest.mark.usefixtures("worker_pool")
@@ -1060,7 +1126,7 @@ async def add_phrase(
return phrase
def hydrated_match(
def phrase_match(
*,
phrase_id: int,
phrase_text: str,
@@ -1070,9 +1136,9 @@ def hydrated_match(
importance: float = 0.8,
allow_nested: bool = False,
suppress_children: bool = True,
) -> HydratedPhraseMatch:
"""Build a hydrated match for overlap tests."""
return HydratedPhraseMatch(
) -> PhraseMatch:
"""Build a metadata-backed phrase match for overlap tests."""
return PhraseMatch(
phrase_id=phrase_id,
matched_norm=phrase_text,
phrase_text=phrase_text,