Compare commits

..
1 Commits
Author SHA1 Message Date
Richie 247e951a27 Remove legacy HTML templates and API service configuration for contact management system
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 32s
test ebook search / test-ebook-search (pull_request) Successful in 35s
build_systems / build-brain (pull_request) Successful in 50s
build_systems / build-bob (pull_request) Successful in 51s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m5s
build_systems / build-jeeves (pull_request) Successful in 2m26s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
test ebook search / test-ebook-search (push) Successful in 30s
build_systems / build-brain (push) Successful in 36s
build_systems / build-bob (push) Successful in 36s
build_systems / build-rhapsody-in-green (push) Successful in 49s
build_systems / build-jeeves (push) Successful in 2m9s
- Deleted base template (base.html) and all related contact templates (contact_detail.html, contact_form.html, contact_list.html, graph.html, need_list.html).
- Removed partial templates for managing contacts and needs (contact_table.html, manage_needs.html, manage_relationships.html, need_items.html).
- Eliminated contact API service configuration (contact_api.nix) from the NixOS setup.
2026-07-15 15:28:15 -04:00
62 changed files with 608 additions and 6947 deletions
-6
View File
@@ -71,7 +71,6 @@
"ehci",
"emerg",
"endlessh",
"ents",
"errorlens",
"esbenp",
"esphome",
@@ -173,8 +172,6 @@
"Networkd",
"networkmanager",
"newtabpage",
"ngram",
"ngrams",
"nixfmt",
"nixos",
"nixpkgs",
@@ -301,9 +298,7 @@
"uiprotect",
"uitour",
"unifi",
"unjudged",
"unrar",
"unstorable",
"unsubmitted",
"uptimekuma",
"urlbar",
@@ -331,7 +326,6 @@
"xcursorgen",
"xdist",
"xhci",
"yake",
"yazi",
"yubikey",
"yubioath",
+1 -4
View File
@@ -25,7 +25,7 @@ dependencies = [
"pydantic",
"pydantic-settings",
"python-multipart",
"sqlalchemy[asyncio]",
"sqlalchemy",
"tenacity",
"tiktoken",
"tinytuya",
@@ -41,10 +41,8 @@ whisper-transcribe = "python.tools.whisper.transcribe:main"
[dependency-groups]
dev = [
"aiosqlite",
"mypy",
"pyfakefs",
"pytest-asyncio",
"pytest-cov",
"pytest-mock",
"pytest-xdist",
@@ -120,6 +118,5 @@ exclude_lines = [
[tool.pytest.ini_options]
addopts = "-n auto -ra --ignore=tests/ebook_search"
asyncio_mode = "auto"
testpaths = ["tests"]
# --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch
@@ -1,206 +0,0 @@
"""adding Phrase metadata tables.
Revision ID: dddee09eddcc
Revises: 96d72c748c24
Create Date: 2026-06-29 00:49:07.344159
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "dddee09eddcc"
down_revision: str | None = "96d72c748c24"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"candidate_phrases",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("source_raw_ngram", sa.Boolean(), nullable=False),
sa.Column("source_yake", sa.Boolean(), nullable=False),
sa.Column("source_spacy_ner", sa.Boolean(), nullable=False),
sa.Column("source_spacy_noun_chunk", sa.Boolean(), nullable=False),
sa.Column("source_capitalized", sa.Boolean(), nullable=False),
sa.Column("source_metadata", sa.Boolean(), nullable=False),
sa.Column("spacy_label", sa.String(), nullable=True),
sa.Column("raw_count", sa.Integer(), nullable=False),
sa.Column("chapter_count", sa.Integer(), nullable=False),
sa.Column("yake_score", sa.Float(), nullable=True),
sa.Column("candidate_score", sa.Float(), nullable=False),
sa.Column(
"sample_contexts",
sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"),
nullable=True,
),
sa.Column("llm_judged", sa.Boolean(), nullable=False),
sa.Column("llm_keep", sa.Boolean(), nullable=True),
sa.Column("llm_confidence", sa.Float(), nullable=True),
sa.Column("llm_category", sa.String(), nullable=True),
sa.Column("llm_reason", sa.Text(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_candidate_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_candidate_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"candidate_phrases_book_norm_idx", "candidate_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index(
"candidate_phrases_book_score_idx",
"candidate_phrases",
["book_id", "candidate_score"],
unique=False,
schema=schema,
)
op.create_table(
"protected_phrases",
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("canonical_id", sa.String(), nullable=False),
sa.Column("phrase_type", sa.String(), nullable=True),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("importance", sa.Float(), nullable=False),
sa.Column("allow_nested", sa.Boolean(), nullable=False),
sa.Column("suppress_children", sa.Boolean(), nullable=False),
sa.Column("source_candidate_id", sa.Integer(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_protected_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_candidate_id"],
[f"{schema}.candidate_phrases.id"],
name=op.f("fk_protected_phrases_source_candidate_id_candidate_phrases"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_protected_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"protected_phrases_book_norm_idx", "protected_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index("protected_phrases_norm_idx", "protected_phrases", ["phrase_norm"], unique=False, schema=schema)
op.create_index(
"protected_phrases_series_norm_idx",
"protected_phrases",
["series_id", "phrase_norm"],
unique=False,
schema=schema,
)
op.create_table(
"chunk_phrase_mentions",
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("start_char", sa.Integer(), nullable=False),
sa.Column("end_char", sa.Integer(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_chunk_phrase_mentions_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["chunk_id"],
[f"{schema}.ebook_chunk.id"],
name=op.f("fk_chunk_phrase_mentions_chunk_id_ebook_chunk"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_chunk_phrase_mentions_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chunk_phrase_mentions")),
sa.UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
schema=schema,
)
op.create_index(
"chunk_phrase_mentions_chunk_idx", "chunk_phrase_mentions", ["chunk_id"], unique=False, schema=schema
)
op.create_index(
"chunk_phrase_mentions_phrase_idx", "chunk_phrase_mentions", ["phrase_id"], unique=False, schema=schema
)
op.create_table(
"phrase_aliases",
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("alias_text", sa.Text(), nullable=False),
sa.Column("alias_norm", sa.Text(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_phrase_aliases_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_phrase_aliases")),
sa.UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
schema=schema,
)
op.create_index("phrase_aliases_norm_idx", "phrase_aliases", ["alias_norm"], unique=False, schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("phrase_aliases_norm_idx", table_name="phrase_aliases", schema=schema)
op.drop_table("phrase_aliases", schema=schema)
op.drop_index("chunk_phrase_mentions_phrase_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_index("chunk_phrase_mentions_chunk_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_table("chunk_phrase_mentions", schema=schema)
op.drop_index("protected_phrases_series_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_book_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_table("protected_phrases", schema=schema)
op.drop_index("candidate_phrases_book_score_idx", table_name="candidate_phrases", schema=schema)
op.drop_index("candidate_phrases_book_norm_idx", table_name="candidate_phrases", schema=schema)
op.drop_table("candidate_phrases", schema=schema)
# ### end Alembic commands ###
@@ -1,55 +0,0 @@
"""remove spaCy-ner.
Revision ID: 751260fc3228
Revises: dddee09eddcc
Create Date: 2026-07-09 23:03:39.554083
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "751260fc3228"
down_revision: str | None = "dddee09eddcc"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("candidate_phrases", "source_spacy_noun_chunk", schema=schema)
op.drop_column("candidate_phrases", "source_spacy_ner", schema=schema)
op.drop_column("candidate_phrases", "spacy_label", schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"candidate_phrases", sa.Column("spacy_label", sa.VARCHAR(), autoincrement=False, nullable=True), schema=schema
)
op.add_column(
"candidate_phrases",
sa.Column("source_spacy_ner", sa.BOOLEAN(), autoincrement=False, nullable=False),
schema=schema,
)
op.add_column(
"candidate_phrases",
sa.Column("source_spacy_noun_chunk", sa.BOOLEAN(), autoincrement=False, nullable=False),
schema=schema,
)
# ### end Alembic commands ###
+12 -13
View File
@@ -8,20 +8,13 @@ from typing import TYPE_CHECKING
from python.ebook_search.llm_interface import request_chat_completion
if TYPE_CHECKING:
import httpx
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.search import SearchResult
logger = logging.getLogger(__name__)
async def answer_query(
client: httpx.AsyncClient,
query: str,
results: list[SearchResult],
config: EbookSearchConfig,
) -> str:
def answer_query(query: str, results: list[SearchResult], config: EbookSearchConfig) -> str:
"""Answer a question using only retrieved chunks."""
if not config.answer_enabled:
logger.info("ebook_answer_skipped_disabled")
@@ -32,15 +25,17 @@ async def answer_query(
return "No relevant sources were found."
logger.info(
f"ebook_answer_request_start {config.vllm_base_url=} {config.chat_model=} sources={len(results)} "
f"query_length={len(query)}"
"ebook_answer_request_start base_url=%s model=%s sources=%s query_length=%s",
config.vllm_base_url,
config.chat_model,
len(results),
len(query),
)
context = "\n\n".join(
f"[{index}] {result.source_title}{' - ' + result.chapter_title if result.chapter_title else ''}\n{result.text}"
for index, result in enumerate(results, start=1)
)
content = await request_chat_completion(
client,
content = request_chat_completion(
config,
[
{
@@ -54,5 +49,9 @@ async def answer_query(
],
)
logger.info(f"ebook_answer_request_complete {config.chat_model=} answer_length={len(content)}")
logger.info(
"ebook_answer_request_complete model=%s answer_length=%s",
config.chat_model,
len(content),
)
return content or "The model returned an empty answer."
+23 -33
View File
@@ -1,22 +1,18 @@
"""Background BM25 refresh tasks for the web app.
The refresh is scheduled on the event loop instead of a thread because the async psycopg
driver only works from the loop; a bare thread cannot open a session on the async engine.
"""
"""Background BM25 refresh tasks for the web app."""
from __future__ import annotations
import asyncio
import logging
from threading import Timer
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.bm25_corpus import load_bm25_corpus, refresh_bm25_corpus
if TYPE_CHECKING:
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Engine
from python.ebook_search.config import EbookSearchConfig
@@ -24,47 +20,41 @@ logger = logging.getLogger(__name__)
def schedule_bm25_refresh(app: FastAPI) -> None:
"""Schedule a delayed BM25 corpus refresh, replacing any pending refresh.
"""Schedule a delayed BM25 corpus refresh, replacing any pending refresh."""
existing_timer = getattr(app.state, "bm25_refresh_timer", None)
if existing_timer is not None:
existing_timer.cancel()
Only called from route handlers, so a running event loop is guaranteed.
"""
cancel_bm25_refresh(app)
loop = asyncio.get_running_loop()
def start_refresh() -> None:
app.state.bm25_refresh_task = loop.create_task(refresh_bm25_for_app(app))
app.state.bm25_refresh_timer = loop.call_later(app.state.config.bm25_refresh_delay_seconds, start_refresh)
logger.info(f"ebook_bm25_refresh_scheduled {app.state.config.bm25_refresh_delay_seconds=}")
timer = Timer(app.state.config.bm25_refresh_delay_seconds, refresh_bm25_for_app, args=(app,))
timer.daemon = True
timer.start()
app.state.bm25_refresh_timer = timer
logger.info(
"ebook_bm25_refresh_scheduled delay_seconds=%s",
app.state.config.bm25_refresh_delay_seconds,
)
def cancel_bm25_refresh(app: FastAPI) -> None:
"""Cancel any pending BM25 corpus refresh timer and in-flight refresh task."""
"""Cancel any pending BM25 corpus refresh."""
existing_timer = getattr(app.state, "bm25_refresh_timer", None)
if existing_timer is not None:
existing_timer.cancel()
app.state.bm25_refresh_timer = None
logger.info("ebook_bm25_refresh_cancelled")
existing_task = getattr(app.state, "bm25_refresh_task", None)
if existing_task is not None:
if not existing_task.done():
existing_task.cancel()
app.state.bm25_refresh_task = None
async def refresh_bm25_for_app(app: FastAPI) -> None:
def refresh_bm25_for_app(app: FastAPI) -> None:
"""Refresh the BM25 corpus using the app engine and config."""
try:
await refresh_bm25_for_engine(app.state.engine, app.state.config)
refresh_bm25_for_engine(app.state.engine, app.state.config)
except Exception:
logger.exception("ebook_bm25_refresh_failed")
async def refresh_bm25_for_engine(engine: AsyncEngine, config: EbookSearchConfig) -> None:
"""Refresh the BM25 corpus using an async SQLAlchemy engine."""
async with AsyncSession(engine) as session:
await refresh_bm25_corpus(session, config)
def refresh_bm25_for_engine(engine: Engine, config: EbookSearchConfig) -> None:
"""Refresh the BM25 corpus using a SQLAlchemy engine."""
with Session(engine) as session:
refresh_bm25_corpus(session, config)
load_bm25_corpus.cache_clear()
logger.info("ebook_bm25_corpus_cache_cleared_after_refresh")
+3 -10
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
from typing import Annotated
import httpx
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Engine
from python.ebook_search.config import EbookSearchConfig
@@ -16,16 +15,10 @@ def get_config(request: Request) -> EbookSearchConfig:
return request.app.state.config
def get_engine(request: Request) -> AsyncEngine:
def get_engine(request: Request) -> Engine:
"""Get the database engine from app state."""
return request.app.state.engine
def get_http_client(request: Request) -> httpx.AsyncClient:
"""Get the shared LLM HTTP client from app state."""
return request.app.state.http_client
AppConfig = Annotated[EbookSearchConfig, Depends(get_config)]
AppEngine = Annotated[AsyncEngine, Depends(get_engine)]
AppHttpClient = Annotated[httpx.AsyncClient, Depends(get_http_client)]
AppEngine = Annotated[Engine, Depends(get_engine)]
-127
View File
@@ -1,127 +0,0 @@
"""Background phrase-judging tasks for the web app.
Judging a book sends one LLM request per candidate phrase, which can take minutes, so it must
not run inside the request where it would block the UI. Judgments run as async FastAPI
background tasks, awaited on the event loop after the response is sent, and are tracked per
book in app state so a second judge request for a book that is already being judged is
rejected instead of doubling the work.
State is loop-confined: every read and mutation happens on the event loop (async route
handlers and async background tasks) and no critical section contains an ``await``, so each
mutation is atomic per loop iteration and no locking is needed.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from python.ebook_search.protected_phrases.judge_ngrams import judge_candidate_phrases_for_books
if TYPE_CHECKING:
from fastapi import BackgroundTasks, FastAPI
logger = logging.getLogger(__name__)
@dataclass
class JudgeTaskState:
"""Running book judgments and last outcome messages, keyed by book id."""
running_book_ids: set[int] = field(default_factory=set)
outcome_messages: dict[int, str] = field(default_factory=dict)
def get_judge_task_state(app: FastAPI) -> JudgeTaskState:
"""Return the app's judge task state, creating it on first use.
Args:
app (FastAPI): App whose state holds the judge task registry.
Returns:
JudgeTaskState: The shared judge task state for this app.
"""
state = getattr(app.state, "judge_tasks", None)
if state is None:
state = JudgeTaskState()
app.state.judge_tasks = state
return state
def start_book_phrase_judgment(app: FastAPI, background_tasks: BackgroundTasks, source_id: int) -> bool:
"""Queue judging of one book's candidate phrases as a FastAPI background task.
The book is claimed before the response returns, so a repeated judge request cannot queue
a second run while one is pending or running.
Args:
app (FastAPI): App supplying the engine, config, and judge task state.
background_tasks (BackgroundTasks): Request's background tasks to queue the judgment on.
source_id (int): Book to judge candidates for.
Returns:
bool: True when a judgment was queued, False when one is already running for this book.
"""
state = get_judge_task_state(app)
if source_id in state.running_book_ids:
logger.info(f"ebook_book_phrase_judgment_already_running {source_id=}")
return False
state.running_book_ids.add(source_id)
state.outcome_messages.pop(source_id, None)
background_tasks.add_task(judge_book_phrases_for_app, app, source_id)
logger.info(f"ebook_book_phrase_judgment_queued {source_id=}")
return True
async def judge_book_phrases_for_app(app: FastAPI, source_id: int) -> None:
"""Judge one book using the app engine and config, recording the outcome message.
Args:
app (FastAPI): App supplying the engine, config, and judge task state.
source_id (int): Book to judge candidates for.
"""
state = get_judge_task_state(app)
try:
result = await judge_candidate_phrases_for_books(app.state.engine, app.state.config, source_ids=[source_id])
logger.info(
f"ebook_book_phrase_judgment_complete {source_id=} {result.candidates_judged=} {result.protected_phrases=} "
f"{result.phrase_mentions=} {result.books_failed=}"
)
if result.books_failed:
message = "Judging failed; see server logs for details"
else:
message = (
f"Judged {result.candidates_judged} candidates; {result.protected_phrases} protected phrases promoted"
)
except Exception:
logger.exception(f"ebook_book_phrase_judgment_task_failed {source_id=}")
message = "Judging failed; see server logs for details"
state.running_book_ids.discard(source_id)
state.outcome_messages[source_id] = message
def is_judging_book(app: FastAPI, source_id: int) -> bool:
"""Report whether a judgment is currently queued or running for one book.
Args:
app (FastAPI): App supplying the judge task state.
source_id (int): Book to check.
Returns:
bool: True while the book's judgment is pending or running.
"""
return source_id in get_judge_task_state(app).running_book_ids
def pop_book_judgment_outcome(app: FastAPI, source_id: int) -> str | None:
"""Return and clear the outcome message from one book's last finished judgment.
Args:
app (FastAPI): App supplying the judge task state.
source_id (int): Book to fetch the outcome for.
Returns:
str | None: The outcome message, or None when there is nothing new to report.
"""
return get_judge_task_state(app).outcome_messages.pop(source_id, None)
+15 -20
View File
@@ -6,12 +6,11 @@ import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Annotated
import httpx
import typer
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.common import configure_logger
from python.ebook_search.api.bm25_tasks import cancel_bm25_refresh
@@ -19,9 +18,8 @@ from python.ebook_search.api.routes import admin_router, health_router, page_rou
from python.ebook_search.api.web import STATIC_DIR
from python.ebook_search.bm25_corpus import ensure_bm25_corpus
from python.ebook_search.config import load_config
from python.ebook_search.protected_phrases.pool import shutdown_extraction_pool
from python.fastapi_tools import ZstdMiddleware
from python.orm.common import get_async_postgres_engine
from python.orm.common import get_postgres_engine
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -37,30 +35,27 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
config = load_config()
app.state.config = config
logger.info(
f"ebook_search_config_loaded {config.top_k=} {config.embedding_model=} {config.embedding_base_url=} "
f"{config.vllm_base_url=} {config.rerank.enabled=} {config.phrase_matching_enabled=} {config.answer_enabled=} "
f"library_paths={len(config.library_paths)}"
"ebook_search_config_loaded top_k=%s embedding_model=%s embedding_base_url=%s vllm_base_url=%s "
"rerank_enabled=%s answer_enabled=%s library_paths=%s",
config.top_k,
config.embedding_model,
config.embedding_base_url,
config.vllm_base_url,
config.rerank.enabled,
config.answer_enabled,
len(config.library_paths),
)
if not config.library_paths:
logger.warning("ebook_search_no_library_paths_configured")
# Concurrent phrase judging opens one session per book worker on this engine, so size the pool
# to cover those plus headroom for ordinary web requests.
app.state.engine = get_async_postgres_engine(
name="RICHIE",
vector_engine=True,
pool_size=config.phrase_judge_book_workers + 10,
)
app.state.http_client = httpx.AsyncClient()
async with AsyncSession(app.state.engine, expire_on_commit=False) as session:
await ensure_bm25_corpus(session, config)
app.state.engine = get_postgres_engine(name="RICHIE", vector_engine=True)
with Session(app.state.engine) as session:
ensure_bm25_corpus(session, config)
try:
yield
finally:
logger.info("ebook_search_shutdown")
cancel_bm25_refresh(app)
shutdown_extraction_pool()
await app.state.http_client.aclose()
await app.state.engine.dispose()
app.state.engine.dispose()
def create_app() -> FastAPI:
+39 -138
View File
@@ -8,18 +8,13 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from python.ebook_search.api.bm25_tasks import schedule_bm25_refresh
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppEngine,
AppHttpClient,
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.api.web import error_response, templates
from python.ebook_search.api.web import templates
from python.ebook_search.embeddings import embed_missing_chunks, embedding_model_stats
from python.ebook_search.ingest import ingest_configured_paths
from python.ebook_search.protected_phrases.generate_ngrams import generate_candidate_phrases_for_books
from python.ebook_search.protected_phrases.judge_ngrams import judge_candidate_phrases_for_books
from python.ebook_search.protected_phrases.store import book_ids_pending_first_judgment, corpus_phrase_stats
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
logger = logging.getLogger(__name__)
@@ -27,143 +22,40 @@ router = APIRouter(prefix="/admin")
@router.get("", response_class=HTMLResponse)
async def admin(request: Request, config: AppConfig, session: AsyncDbSession) -> HTMLResponse:
def admin(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Render the admin page."""
stats = await embedding_model_stats(session)
phrase_stats = await corpus_phrase_stats(session)
logger.info(
f"ebook_admin_page_loaded models={len(stats)} {phrase_stats.candidate_phrases=} "
f"{phrase_stats.protected_phrases=}"
)
return templates.TemplateResponse(
request,
"admin.html",
{"config": config, "stats": stats, "phrase_stats": phrase_stats},
)
stats = embedding_model_stats(session)
logger.info("ebook_admin_page_loaded models=%s", len(stats))
return templates.TemplateResponse(request, "admin.html", {"config": config, "stats": stats})
@router.post("/scan", response_class=HTMLResponse)
async def scan_library(request: Request, config: AppConfig, session: AsyncDbSession) -> HTMLResponse:
def scan_library(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Scan configured library paths for EPUB changes."""
try:
count = await ingest_configured_paths(session, config)
await session.commit()
count = ingest_configured_paths(session, config)
session.commit()
except Exception as error:
logger.exception("ebook_admin_scan_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
logger.info(f"ebook_admin_scan_complete {count=}")
logger.info("ebook_admin_scan_complete changed_files=%s", count)
if count > 0:
schedule_bm25_refresh(request.app)
return templates.TemplateResponse(request, "partials/admin_status.html", {"message": f"Indexed {count} EPUBs"})
@router.post("/phrases/generate-all", response_class=HTMLResponse)
async def generate_all_phrases(request: Request, config: AppConfig, engine: AppEngine) -> HTMLResponse:
"""Regenerate candidate phrases for every indexed book without LLM judging."""
try:
result = await generate_candidate_phrases_for_books(engine, config)
except Exception as error:
logger.exception("ebook_admin_generate_phrases_failed")
return error_response(request, error)
logger.info(
f"ebook_admin_generate_phrases_complete {result.books_seen=} {result.books_built=} {result.candidate_phrases=}"
)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{
"message": (
f"Generated phrases for {result.books_built} of {result.books_seen} books; "
f"{result.candidate_phrases} candidates stored"
)
},
)
@router.post("/phrases/judge-all", response_class=HTMLResponse)
async def judge_all_phrases(request: Request, engine: AppEngine, config: AppConfig) -> HTMLResponse:
"""Judge unjudged candidate phrases across every indexed book."""
return await run_phrase_judgment(request, engine, config, source_ids=None)
@router.post("/phrases/judge-missing", response_class=HTMLResponse)
async def judge_missing_phrases(
request: Request,
engine: AppEngine,
config: AppConfig,
session: AsyncDbSession,
) -> HTMLResponse:
"""Judge candidate phrases only for books where judging has never run."""
source_ids = await book_ids_pending_first_judgment(session)
if not source_ids:
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{"message": "All books with candidate phrases have been judged"},
)
return await run_phrase_judgment(request, engine, config, source_ids=source_ids)
async def run_phrase_judgment(
request: Request,
engine: AppEngine,
config: AppConfig,
*,
source_ids: list[int] | None,
) -> HTMLResponse:
"""Run LLM judging for candidate phrases and render the outcome as an admin status partial.
Args:
request (Request): Current request, for template rendering.
engine (AppEngine): Engine used to open per-book judging sessions.
config (AppConfig): Runtime phrase-tuning settings.
source_ids (list[int] | None): Books to judge; ``None`` judges every indexed book.
Returns:
HTMLResponse: Status partial describing the judging outcome.
"""
try:
result = await judge_candidate_phrases_for_books(engine, config, source_ids=source_ids)
except Exception as error:
logger.exception("ebook_admin_judge_phrases_failed")
return error_response(request, error)
logger.info(
f"ebook_admin_judge_phrases_complete {result.books_seen=} {result.books_judged=} {result.books_failed=} "
f"{result.candidates_judged=} {result.protected_phrases=} {result.phrase_mentions=}"
)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{
"message": (
f"Judged {result.candidates_judged} candidates across {result.books_judged} of "
f"{result.books_seen} books; {result.protected_phrases} protected phrases, "
f"{result.phrase_mentions} mentions"
+ (f"; {result.books_failed} books failed" if result.books_failed else "")
)
},
)
@router.post("/embed-missing", response_class=HTMLResponse)
async def embed_missing(
request: Request,
config: AppConfig,
session: AsyncDbSession,
client: AppHttpClient,
) -> HTMLResponse:
def embed_missing(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Embed chunks missing vectors for the configured model."""
try:
count = await embed_missing_chunks(session, client, config)
await session.commit()
count = embed_missing_chunks(session, config)
session.commit()
except Exception as error:
logger.exception("ebook_admin_embed_missing_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
logger.info(f"ebook_admin_embed_missing_complete {count=}")
logger.info("ebook_admin_embed_missing_complete chunks=%s", count)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
@@ -172,29 +64,38 @@ async def embed_missing(
@router.post("/embed-all", response_class=HTMLResponse)
async def embed_all(
request: Request,
config: AppConfig,
session: AsyncDbSession,
client: AppHttpClient,
) -> HTMLResponse:
def embed_all(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Embed all chunks missing vectors in fixed-size batches."""
total = 0
batches = 0
try:
while True:
count = await embed_missing_chunks(session, client, config)
count = embed_missing_chunks(session, config)
if count == 0:
break
await session.commit()
session.commit()
total += count
batches += 1
logger.info(f"ebook_admin_embed_all_batch_complete {batches=} {count=} {total=}")
logger.info(
"ebook_admin_embed_all_batch_complete batch=%s chunks=%s total_chunks=%s",
batches,
count,
total,
)
except Exception as error:
logger.exception(f"ebook_admin_embed_all_failed {batches=} {total=}")
return error_response(request, f"Embed all failed after {total} chunks in {batches} batches: {error}")
logger.exception(
"ebook_admin_embed_all_failed batches=%s chunks=%s",
batches,
total,
)
return templates.TemplateResponse(
request,
"partials/error.html",
{"message": f"Embed all failed after {total} chunks in {batches} batches: {error}"},
status_code=500,
)
logger.info(f"ebook_admin_embed_all_complete {batches=} {total=}")
logger.info("ebook_admin_embed_all_complete batches=%s chunks=%s", batches, total)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
+22 -17
View File
@@ -11,17 +11,15 @@ from fastapi.responses import JSONResponse
from sqlalchemy import literal, select
from sqlalchemy.exc import SQLAlchemyError
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppHttpClient,
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.bm25_corpus import bm25_index_exists, bm25_index_path, read_bm25_manifest
from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
if TYPE_CHECKING:
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -31,17 +29,17 @@ router = APIRouter()
@router.get("/health")
async def health() -> dict[str, str]:
def health() -> dict[str, str]:
"""Liveness probe that returns ok without touching dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClient) -> JSONResponse:
def ready(config: AppConfig, session: DbSession) -> JSONResponse:
"""Readiness probe reporting database, embedding endpoint, and BM25 index status."""
database_ok = await check_database(session)
embedding_ok = await check_embedding_endpoint(client, config)
chat_status = await chat_endpoint_status(client, config)
database_ok = check_database(session)
embedding_ok = check_embedding_endpoint(config)
chat_status = chat_endpoint_status(config)
bm25_status = check_bm25_status(config)
checks = {
@@ -60,23 +58,30 @@ async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClien
status = "ready"
status_code = HTTPStatus.OK
logger.info(f"ebook_ready_check {status=} {database_ok=} {embedding_ok=} {chat_status=} {bm25_status=}")
logger.info(
"ebook_ready_check status=%s database=%s embedding=%s chat=%s bm25=%s",
status,
database_ok,
embedding_ok,
chat_status,
bm25_status,
)
return JSONResponse(content={"status": status, "checks": checks}, status_code=status_code)
async def chat_endpoint_status(client: httpx.AsyncClient, config: EbookSearchConfig) -> str:
def chat_endpoint_status(config: EbookSearchConfig) -> str:
"""Return the answering chat endpoint status, or disabled when answers are off."""
if not config.answer_enabled:
return "disabled"
return "ok" if await check_chat_endpoint(client, config) else "fail"
return "ok" if check_chat_endpoint(config) else "fail"
async def check_database(session: AsyncSession) -> bool:
def check_database(session: Session) -> bool:
"""Return whether the database answers a trivial query."""
try:
await session.execute(select(literal(1)))
session.execute(select(literal(1)))
except SQLAlchemyError as error:
logger.warning(f"ebook_ready_database_unavailable {error=}")
logger.warning("ebook_ready_database_unavailable error=%s", error)
return False
return True
+19 -148
View File
@@ -3,24 +3,17 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.api.judge_tasks import is_judging_book, pop_book_judgment_outcome, start_book_phrase_judgment
from python.ebook_search.api.web import templates
from python.ebook_search.protected_phrases.generate_ngrams import recalculate_candidate_phrases_for_book
from python.ebook_search.protected_phrases.store import count_protected_phrases
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.orm.richie import EbookCandidatePhrase, EbookChapter, EbookChunk, EbookProtectedPhrase, EbookSource
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.orm.richie import EbookSource
logger = logging.getLogger(__name__)
@@ -28,160 +21,38 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, config: AppConfig) -> HTMLResponse:
def index(request: Request, config: AppConfig) -> HTMLResponse:
"""Render the search page."""
return templates.TemplateResponse(request, "search.html", {"config": config})
@router.get("/books", response_class=HTMLResponse)
async def books(request: Request, session: AsyncDbSession) -> HTMLResponse:
def books(request: Request, session: DbSession) -> HTMLResponse:
"""Render the indexed books page."""
sources = list((await session.scalars(select(EbookSource).order_by(EbookSource.title))).all())
logger.info(f"ebook_books_page_loaded count={len(sources)}")
sources = list(session.scalars(select(EbookSource).order_by(EbookSource.title)).all())
logger.info("ebook_books_page_loaded count=%s", len(sources))
return templates.TemplateResponse(request, "books.html", {"sources": sources})
async def get_chapter_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed chapters for one book."""
return await session.scalar(select(func.count(EbookChapter.id)).where(EbookChapter.source_id == book_id)) or 0
async def get_chunk_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed chunks for one book."""
return await session.scalar(select(func.count(EbookChunk.id)).where(EbookChunk.source_id == book_id)) or 0
async def get_candidate_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed candidates for one book."""
return (
await session.scalar(select(func.count(EbookCandidatePhrase.id)).where(EbookCandidatePhrase.book_id == book_id))
or 0
)
async def get_judged_candidate_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of judged candidates for one book."""
return (
await session.scalar(
select(func.count(EbookCandidatePhrase.id)).where(
EbookCandidatePhrase.book_id == book_id,
EbookCandidatePhrase.llm_judged.is_(True),
)
)
or 0
)
async def get_candidates(session: AsyncSession, book_id: int) -> list[EbookCandidatePhrase]:
"""Return the indexed candidates for one book."""
return list(
await session.scalars(
select(EbookCandidatePhrase)
.where(EbookCandidatePhrase.book_id == book_id)
.order_by(EbookCandidatePhrase.candidate_score.desc())
.limit(100)
)
)
async def get_protected_phrases(session: AsyncSession, book_id: int) -> list[EbookProtectedPhrase]:
"""Return the protected phrases for one book."""
return list(
await session.scalars(
select(EbookProtectedPhrase)
.where(EbookProtectedPhrase.book_id == book_id)
.order_by(EbookProtectedPhrase.importance.desc())
.limit(100)
)
)
@router.get("/books/{source_id}", response_class=HTMLResponse)
async def book_detail(source_id: int, request: Request, session: AsyncDbSession) -> HTMLResponse:
def book_detail(source_id: int, request: Request, session: DbSession) -> HTMLResponse:
"""Render details for one indexed book."""
source = await session.get(EbookSource, source_id)
phrase_status_message = None
recalculated = request.query_params.get("phrases_recalculated")
if recalculated is not None:
phrase_status_message = f"Recalculated phrases; {recalculated} candidates generated"
judgment_outcome = pop_book_judgment_outcome(request.app, source_id)
if judgment_outcome is not None:
phrase_status_message = judgment_outcome
judging_in_progress = is_judging_book(request.app, source_id)
if judging_in_progress:
phrase_status_message = "Judging candidate phrases in the background; refresh to see progress"
source = session.get(EbookSource, source_id)
if source is not None:
chapter_count = await get_chapter_count(session, source.id)
chunk_count = await get_chunk_count(session, source.id)
candidate_count = await get_candidate_count(session, source.id)
judged_candidate_count = await get_judged_candidate_count(session, source.id)
protected_count = await count_protected_phrases(session, source.id)
candidates = await get_candidates(session, source.id)
protected_phrases = await get_protected_phrases(session, source.id)
chapter_count = len(source.chapters)
chunk_count = len(source.chunks)
else:
chapter_count = 0
chunk_count = 0
candidate_count = 0
judged_candidate_count = 0
protected_count = 0
candidates = []
protected_phrases = []
logger.info(
f"ebook_book_detail_loaded {source_id=} found={source is not None} {chapter_count=} {chunk_count=} "
f"{candidate_count=} {judged_candidate_count=} {protected_count=}"
"ebook_book_detail_loaded source_id=%s found=%s chapters=%s chunks=%s",
source_id,
source is not None,
chapter_count,
chunk_count,
)
return templates.TemplateResponse(
request,
"book_detail.html",
{
"candidate_count": candidate_count,
"candidates": candidates,
"chapter_count": chapter_count,
"chunk_count": chunk_count,
"judged_candidate_count": judged_candidate_count,
"judging_in_progress": judging_in_progress,
"protected_count": protected_count,
"protected_phrases": protected_phrases,
"phrase_status_message": phrase_status_message,
"source": source,
},
{"chapter_count": chapter_count, "chunk_count": chunk_count, "source": source},
)
@router.post("/books/{source_id}/recalculate-phrases")
async def recalculate_book_phrases(source_id: int, config: AppConfig, session: AsyncDbSession) -> RedirectResponse:
"""Clear and regenerate candidate phrases for one indexed book."""
source = await session.get(EbookSource, source_id)
if source is None:
raise HTTPException(status_code=404, detail="Book not found")
try:
result = await recalculate_candidate_phrases_for_book(session, source, config)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
logger.info(
f"ebook_book_phrase_recalculation_complete {source_id=} {result.candidate_phrases=} "
f"{result.deleted_candidates=} {result.deleted_protected_phrases=} {result.deleted_aliases=} "
f"{result.deleted_mentions=}"
)
return RedirectResponse(
url=f"/books/{source_id}?phrases_recalculated={result.candidate_phrases}",
status_code=303,
)
@router.post("/books/{source_id}/judge-phrases")
async def judge_book_phrases(
source_id: int,
request: Request,
background_tasks: BackgroundTasks,
session: AsyncDbSession,
) -> RedirectResponse:
"""Queue background judging of one book's candidate phrases and return immediately."""
source = await session.get(EbookSource, source_id)
if source is None:
raise HTTPException(status_code=404, detail="Book not found")
started = start_book_phrase_judgment(request.app, background_tasks, source.id)
logger.info(f"ebook_book_phrase_judgment_requested {source_id=} {started=}")
return RedirectResponse(url=f"/books/{source_id}", status_code=303)
+22 -29
View File
@@ -14,9 +14,8 @@ from python.ebook_search.answer import answer_query
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppEngine,
AppHttpClient,
)
from python.ebook_search.api.web import error_response, templates
from python.ebook_search.api.web import templates
from python.ebook_search.guardrails import (
CitationReport,
is_confident,
@@ -27,8 +26,6 @@ from python.ebook_search.search import SearchResponse, search_ebooks
from python.ebook_search.timing import runtime_step_from_start
if TYPE_CHECKING:
import httpx
from python.ebook_search.config import EbookSearchConfig
logger = logging.getLogger(__name__)
@@ -36,8 +33,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
async def build_answer(
client: httpx.AsyncClient,
def build_answer(
query: str,
response: SearchResponse,
config: EbookSearchConfig,
@@ -49,8 +45,9 @@ async def build_answer(
if not is_confident(response.results, config):
logger.info(
f"ebook_answer_low_confidence confidence={retrieval_confidence(response.results):.4f} "
f"{config.min_retrieval_confidence=:.4f}"
"ebook_answer_low_confidence confidence=%.4f threshold=%.4f",
retrieval_confidence(response.results),
config.min_retrieval_confidence,
)
answer = (
"Retrieval confidence is low for this query, so answer generation was skipped. "
@@ -59,46 +56,40 @@ async def build_answer(
return answer, True, None
try:
answer = await answer_query(client, query, response.results, config)
answer = answer_query(query, response.results, config)
except RuntimeError as error:
logger.warning(f"ebook_answer_request_failed_falling_back {error=}")
logger.warning("ebook_answer_request_failed_falling_back error=%s", error)
return "Answer generation failed. Source chunks are still shown below.", False, None
citation_report = None
if config.validate_citations_enabled and response.results:
citation_report = validate_citations(answer, len(response.results))
if citation_report.invalid or not citation_report.grounded:
logger.warning(f"ebook_answer_citation_issue {citation_report.invalid=} {citation_report.grounded=}")
logger.warning(
"ebook_answer_citation_issue invalid=%s grounded=%s",
citation_report.invalid,
citation_report.grounded,
)
return answer, False, citation_report
@router.post("/search", response_class=HTMLResponse)
async def search(
def search(
request: Request,
config: AppConfig,
engine: AppEngine,
client: AppHttpClient,
query: Annotated[str, Form()],
*,
rerank: Annotated[bool, Form()] = False,
phrase_matching: Annotated[bool, Form()] = False,
rerank: Annotated[str | None, Form()] = None,
) -> HTMLResponse:
"""Run a search and render HTMX results."""
try:
response = await search_ebooks(
engine,
client,
query,
config,
rerank=rerank,
phrase_matching=phrase_matching,
)
response = search_ebooks(engine, query, config, rerank=rerank == "true")
except Exception as error:
logger.exception("ebook_search_request_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
answer_start = perf_counter()
answer, low_confidence, citation_report = await build_answer(client, query, response, config)
answer, low_confidence, citation_report = build_answer(query, response, config)
answer_step_name = "Answer generation" if config.answer_enabled else "Answer skipped"
response = replace(
response,
@@ -106,10 +97,12 @@ async def search(
)
for step in response.timings:
logger.info(f"ebook_search_timing {step.name=} {step.duration_ms=:.1f}")
logger.info("ebook_search_timing step=%r runtime_ms=%.1f", step.name, step.duration_ms)
logger.info(
f"ebook_search_request_complete results={len(response.results)} {response.rank_label=} "
f"{response.total_runtime_ms=:.1f}"
"ebook_search_request_complete results=%s rank_label=%s runtime_ms=%.1f",
len(response.results),
response.rank_label,
response.total_runtime_ms,
)
return templates.TemplateResponse(
request,
-33
View File
@@ -181,12 +181,6 @@ textarea:focus {
flex-wrap: wrap;
}
.search-toggles {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
button {
padding: 10px 20px;
font: inherit;
@@ -218,11 +212,6 @@ button:hover {
margin-bottom: 24px;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(2, max-content);
}
/* Answer + results */
#results {
display: block;
@@ -314,28 +303,6 @@ button:hover {
font-variant-numeric: tabular-nums;
}
.phrase-matches {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
margin: 10px 0 0;
font-size: 0.78rem;
}
.phrase-matches-label {
color: var(--muted);
font-weight: 600;
}
.phrase-match {
padding: 3px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--accent);
}
/* Runtime — developer diagnostics, hidden unless dev mode is on */
.runtime {
display: none;
+44 -102
View File
@@ -1,103 +1,45 @@
{% extends "base.html" %} {% block title %}EPUB Admin{% endblock %} {% block
head %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% endblock %} {% block content %}
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<section class="actions">
<form
hx-post="/admin/embed-missing"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Embed</button>
</form>
<form
hx-post="/admin/embed-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Embed all</button>
</form>
</section>
<table>
<thead>
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section>
<h2>Protected phrases</h2>
<section class="actions actions-grid">
<form
hx-post="/admin/phrases/generate-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Regenerate all phrases</button>
</form>
<form
hx-post="/admin/phrases/judge-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Judge all phrases</button>
</form>
<form
hx-post="/admin/phrases/judge-missing"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Judge missing phrases</button>
</form>
</section>
<table>
<thead>
<tr>
<th>Candidates</th>
<th>Judged</th>
<th>Unjudged</th>
<th>Protected</th>
<th>Books indexed</th>
<th>Books generated</th>
<th>Books fully judged</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ phrase_stats.candidate_phrases }}</td>
<td>{{ phrase_stats.judged_candidates }}</td>
<td>{{ phrase_stats.unjudged_candidates }}</td>
<td>{{ phrase_stats.protected_phrases }}</td>
<td>{{ phrase_stats.total_books }}</td>
<td>{{ phrase_stats.books_with_candidates }}</td>
<td>{{ phrase_stats.books_fully_judged }}</td>
</tr>
</tbody>
</table>
</section>
{% extends "base.html" %}
{% block title %}EPUB Admin{% endblock %}
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
{% block content %}
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed</button>
</form>
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed all</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
@@ -6,9 +6,6 @@
{% if source %}
<h1>{{ source.title }}</h1>
<p class="meta">{{ source.author or "Unknown author" }}</p>
{% if phrase_status_message %}
<p class="status">{{ phrase_status_message }}</p>
{% endif %}
<dl class="card">
<dt>File</dt>
<dd>{{ source.file_path }}</dd>
@@ -16,93 +13,7 @@
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
<dt>Candidates</dt>
<dd>{{ candidate_count }}</dd>
<dt>Judged</dt>
<dd>{{ judged_candidate_count }}</dd>
<dt>Protected</dt>
<dd>{{ protected_count }}</dd>
</dl>
<form
method="post"
action="/books/{{ source.id }}/recalculate-phrases"
onsubmit="return confirm('Remove old phrases for this book and generate new candidates?');"
>
<button type="submit">Recalculate phrases</button>
</form>
<form
method="post"
action="/books/{{ source.id }}/judge-phrases"
onsubmit="return confirm('Judge candidate phrases for this book with the LLM?');"
>
<button type="submit"{% if judging_in_progress %} disabled{% endif %}>
{% if judging_in_progress %}Judging&hellip;{% else %}Judge phrases{% endif %}
</button>
</form>
<section>
<h2>Candidate n-grams</h2>
{% if candidates %}
<table>
<thead>
<tr>
<th>Phrase</th>
<th>Status</th>
<th>Score</th>
<th>Count</th>
<th>Chapters</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>{{ candidate.phrase_text }}</td>
<td>
{% if candidate.llm_judged %}
{% if candidate.llm_keep %}Kept{% else %}Rejected{% endif %}
{% else %}
Candidate
{% endif %}
</td>
<td>{{ "%.2f"|format(candidate.candidate_score) }}</td>
<td>{{ candidate.raw_count }}</td>
<td>{{ candidate.chapter_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No candidate n-grams.</p>
{% endif %}
</section>
<section>
<h2>Protected phrases</h2>
{% if protected_phrases %}
<table>
<thead>
<tr>
<th>Phrase</th>
<th>Type</th>
<th>Confidence</th>
<th>Importance</th>
</tr>
</thead>
<tbody>
{% for phrase in protected_phrases %}
<tr>
<td>{{ phrase.phrase_text }}</td>
<td>{{ phrase.phrase_type or "phrase" }}</td>
<td>{{ "%.2f"|format(phrase.confidence) }}</td>
<td>{{ "%.2f"|format(phrase.importance) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No protected phrases.</p>
{% endif %}
</section>
{% else %}
<h1>Book not found</h1>
{% endif %}
@@ -82,14 +82,6 @@
</div>
{% endif %}
</dl>
{% if result.matched_phrases %}
<p class="phrase-matches">
<span class="phrase-matches-label">boosted by</span>
{% for phrase in result.matched_phrases %}
<span class="phrase-match">{{ phrase }}</span>
{% endfor %}
</p>
{% endif %}
</li>
{% endfor %}
</ol>
+5 -17
View File
@@ -7,24 +7,12 @@
<h1>Search</h1>
<form class="card" hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">What are you looking for?</label>
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required
onkeydown="if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.form.requestSubmit(); }"></textarea>
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea>
<div class="form-row">
<div class="search-toggles">
<label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank
</label>
<label class="check">
<input
type="checkbox"
name="phrase_matching"
value="true"
{% if config.phrase_matching_enabled %}checked{% endif %}
>
Phrase matching
</label>
</div>
<label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank
</label>
<button type="submit">Search</button>
</div>
</form>
-10
View File
@@ -3,14 +3,9 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi.templating import Jinja2Templates
if TYPE_CHECKING:
from fastapi import Request
from fastapi.responses import HTMLResponse
PACKAGE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static"
@@ -26,8 +21,3 @@ def static_version(filename: str) -> int:
templates = Jinja2Templates(directory=TEMPLATE_DIR)
templates.env.globals["static_version"] = static_version
def error_response(request: Request, message: object) -> HTMLResponse:
"""Render the shared error partial for a failed UI request."""
return templates.TemplateResponse(request, "partials/error.html", {"message": str(message)}, status_code=500)
+35 -28
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import json
import logging
import shutil
@@ -15,11 +14,10 @@ from typing import TYPE_CHECKING
import bm25s
from sqlalchemy import func, select, union_all
from python.ebook_search.chunk_records import CHUNK_RECORD_COLUMNS
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -75,48 +73,52 @@ def get_current_bm25_index(index_path: Path) -> Path:
return index_path
async def ensure_bm25_corpus(session: AsyncSession, config: EbookSearchConfig) -> None:
def ensure_bm25_corpus(session: Session, config: EbookSearchConfig) -> None:
"""Create or refresh the persisted BM25 corpus when it is missing or stale."""
index_path = bm25_index_path(config)
manifest = read_bm25_manifest(index_path)
db_updated_at = await corpus_last_updated_at(session)
db_updated_at = corpus_last_updated_at(session)
if not bm25_index_exists(index_path, manifest):
logger.info(f"ebook_bm25_index_missing {index_path=}")
await refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
logger.info("ebook_bm25_index_missing path=%s", index_path)
refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
return
if db_updated_at is not None and manifest is not None and manifest.created_at < db_updated_at:
logger.info(
f"ebook_bm25_index_stale {index_path=} created_at={manifest.created_at.isoformat()} "
f"db_updated_at={db_updated_at.isoformat()}"
"ebook_bm25_index_stale path=%s created_at=%s db_updated_at=%s",
index_path,
manifest.created_at.isoformat(),
db_updated_at.isoformat(),
)
await refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
return
logger.info(
f"ebook_bm25_index_current {index_path=} chunks={manifest.chunk_count if manifest else 0} "
f"created_at={manifest.created_at.isoformat() if manifest else None}"
"ebook_bm25_index_current path=%s chunks=%s created_at=%s",
index_path,
manifest.chunk_count if manifest else 0,
manifest.created_at.isoformat() if manifest else None,
)
async def refresh_bm25_corpus(
session: AsyncSession,
def refresh_bm25_corpus(
session: Session,
config: EbookSearchConfig,
*,
db_updated_at: datetime | None = None,
) -> BM25Manifest:
"""Rebuild and persist the BM25 corpus from the current database chunks.
The index build is CPU and disk work, so it runs in a worker thread.
"""
"""Rebuild and persist the BM25 corpus from the current database chunks."""
index_path = bm25_index_path(config)
records, texts = await fetch_bm25_corpus_records(session)
records, texts = fetch_bm25_corpus_records(session)
manifest = BM25Manifest(
created_at=datetime.now(tz=UTC),
db_updated_at=db_updated_at if db_updated_at is not None else await corpus_last_updated_at(session),
db_updated_at=db_updated_at if db_updated_at is not None else corpus_last_updated_at(session),
chunk_count=len(records),
)
await asyncio.to_thread(write_bm25_corpus, index_path, records, texts, manifest)
write_bm25_corpus(index_path, records, texts, manifest)
logger.info(
f"ebook_bm25_index_refreshed {index_path=} {manifest.chunk_count=} created_at={manifest.created_at.isoformat()}"
"ebook_bm25_index_refreshed path=%s chunks=%s created_at=%s",
index_path,
manifest.chunk_count,
manifest.created_at.isoformat(),
)
return manifest
@@ -129,7 +131,7 @@ def load_bm25_corpus(config: EbookSearchConfig) -> BM25Corpus:
"""
index_path = bm25_index_path(config)
active_index_path = get_current_bm25_index(index_path)
logger.info(f"ebook_bm25_corpus_cache_load {index_path=} {active_index_path=}")
logger.info("ebook_bm25_corpus_cache_load path=%s active_path=%s", index_path, active_index_path)
manifest = read_bm25_manifest(index_path)
if manifest is None or not bm25_index_exists(index_path, manifest):
msg = f"BM25 corpus is not available: {index_path}"
@@ -162,7 +164,7 @@ def score_bm25_corpus(query: str, corpus: BM25Corpus, *, limit: int) -> list[tup
return results
async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[str, object]], list[str]]:
def fetch_bm25_corpus_records(session: Session) -> tuple[list[dict[str, object]], list[str]]:
"""Fetch persistable BM25 corpus records and their matching index texts from the database.
search_text is only needed to build the index, so it is returned separately instead of
@@ -170,7 +172,12 @@ async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[st
"""
statement = (
select(
*CHUNK_RECORD_COLUMNS,
EbookChunk.id.label("chunk_id"),
EbookChunk.text.label("text"),
EbookSource.title.label("source_title"),
EbookSource.author.label("source_author"),
EbookChapter.title.label("chapter_title"),
EbookChunk.page_label.label("page_label"),
EbookChunk.search_text.label("bm25_text"),
)
.select_from(EbookChunk)
@@ -180,21 +187,21 @@ async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[st
)
records: list[dict[str, object]] = []
texts: list[str] = []
for row in (await session.execute(statement)).mappings():
for row in session.execute(statement).mappings():
record = dict(row)
texts.append(str(record.pop("bm25_text")))
records.append(record)
return records, texts
async def corpus_last_updated_at(session: AsyncSession) -> datetime | None:
def corpus_last_updated_at(session: Session) -> datetime | None:
"""Return the latest source/chapter/chunk update timestamp relevant to BM25 text."""
update_times = union_all(
select(func.max(EbookSource.updated).label("updated")),
select(func.max(EbookChapter.updated).label("updated")),
select(func.max(EbookChunk.updated).label("updated")),
).subquery()
return await session.scalar(select(func.max(update_times.c.updated)))
return session.scalar(select(func.max(update_times.c.updated)))
def write_bm25_corpus(
-13
View File
@@ -1,13 +0,0 @@
"""Shared database columns used to build search-result records."""
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
CHUNK_RECORD_COLUMNS = (
EbookChunk.id.label("chunk_id"),
EbookChunk.text.label("text"),
EbookSource.id.label("source_id"),
EbookSource.title.label("source_title"),
EbookSource.author.label("source_author"),
EbookChapter.title.label("chapter_title"),
EbookChunk.page_label.label("page_label"),
)
+6 -19
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from os import getenv
from typing import Annotated, Self
from pydantic import AliasChoices, Field, field_validator, model_validator
@@ -31,6 +32,11 @@ def normalize_embedding_alias(model: str) -> str:
return standard_model
def normalize_embedding_model(default: str = "qwen3-embedding-0.6b") -> str:
"""Normalize the configured embedding alias to its provider model name."""
return normalize_embedding_alias(getenv("EBOOK_SEARCH_EMBEDDING_MODEL", default))
class RerankConfig(BaseSettings):
"""vLLM reranker settings."""
@@ -80,25 +86,6 @@ class EbookSearchConfig(BaseSettings):
validate_citations_enabled: bool = True
bm25_index_dir: str = ".ebook_search_bm25"
bm25_refresh_delay_seconds: int = 60
protected_phrase_max_candidates_per_book: int = 5000
protected_phrase_llm_candidates_per_book: int = 500
protected_phrase_extraction_workers: int = 16
phrase_judge_book_workers: int = 20
phrase_judge_phrase_workers: int = 100
protected_phrase_confidence_threshold: float = 0.80
phrase_matching_enabled: bool = True
phrase_hit_boost: float = 0.25
phrase_min_tokens: int = 2
phrase_max_tokens: int = 5
phrase_max_entity_tokens: int = 8
phrase_raw_ngram_min_count: int = 2
phrase_raw_count_score_threshold: int = 3
phrase_raw_count_high_score_threshold: int = 10
phrase_chapter_count_score_threshold: int = 2
phrase_chapter_count_high_score_threshold: int = 5
phrase_target_protected_per_book: int = 100
phrase_default_allow_nested: bool = False
phrase_default_suppress_children: bool = True
@field_validator("library_paths", mode="before")
@classmethod
+1 -1
View File
@@ -32,7 +32,7 @@ def docker_run(
capture_output: bool = False,
) -> subprocess.CompletedProcess[str]:
"""Run docker with repo-root cwd and consistent error handling."""
logger.info(f"docker {' '.join(arguments)}")
logger.info("docker %s", " ".join(arguments))
return subprocess.run(
["docker", *arguments],
cwd=get_repo_dir(),
+1 -4
View File
@@ -16,7 +16,7 @@ dependencies = [
"pydantic",
"pydantic-settings",
"python-multipart",
"sqlalchemy[asyncio]",
"sqlalchemy",
"tiktoken",
"typer",
"uvicorn[standard]",
@@ -25,9 +25,7 @@ dependencies = [
[dependency-groups]
dev = [
"aiosqlite",
"pytest",
"pytest-asyncio",
"pytest-mock",
"pytest-xdist",
]
@@ -37,5 +35,4 @@ package = false
[tool.pytest.ini_options]
addopts = "-n auto -ra"
asyncio_mode = "auto"
testpaths = ["tests/ebook_search"]
+2 -36
View File
@@ -2,15 +2,6 @@ version = 1
revision = 3
requires-python = "==3.14.*"
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "alembic"
version = "1.18.5"
@@ -162,7 +153,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "sqlalchemy" },
{ name = "tiktoken" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
@@ -171,9 +162,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "aiosqlite" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "pytest-xdist" },
]
@@ -192,7 +181,7 @@ requires-dist = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extras = ["asyncio"] },
{ name = "sqlalchemy" },
{ name = "tiktoken" },
{ name = "typer" },
{ name = "uvicorn", extras = ["standard"] },
@@ -201,9 +190,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "aiosqlite" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "pytest-xdist" },
]
@@ -255,9 +242,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
@@ -265,9 +250,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
@@ -682,18 +665,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
@@ -897,11 +868,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "1.3.1"
+35 -30
View File
@@ -23,8 +23,7 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Sequence
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -66,45 +65,51 @@ class EmbeddingModelStats:
return max(self.total_chunks - self.embedded_chunks, 0)
async def embed_texts(
client: httpx.AsyncClient,
texts: Sequence[str],
config: EbookSearchConfig,
) -> list[list[float]]:
def embed_texts(texts: Sequence[str], config: EbookSearchConfig) -> list[list[float]]:
"""Embed text with the configured vLLM embedding model."""
logger.info(f"ebook_embed_request_start {config.embedding_base_url=} {config.embedding_model=} count={len(texts)}")
vectors = await request_embeddings(client, texts, config)
logger.info(
"ebook_embed_request_start base_url=%s model=%s count=%s",
config.embedding_base_url,
config.embedding_model,
len(texts),
)
vectors = request_embeddings(texts, config)
expected_dimension = MODEL_DIMENSIONS[config.embedding_model]
for vector in vectors:
if len(vector) != expected_dimension:
msg = f"Expected {expected_dimension} dimensions, got {len(vector)}"
raise ValueError(msg)
logger.info(f"ebook_embed_request_complete {config.embedding_model=} count={len(vectors)} {expected_dimension=}")
logger.info(
"ebook_embed_request_complete model=%s count=%s dimension=%s",
config.embedding_model,
len(vectors),
expected_dimension,
)
return vectors
async def embed_query(client: httpx.AsyncClient, query: str, config: EbookSearchConfig) -> list[float]:
def embed_query(query: str, config: EbookSearchConfig) -> list[float]:
"""Embed a search query with the Qwen retrieval instruction."""
instructed_query = f"Instruct: Retrieve relevant passages for the query.\nQuery: {query}"
return (await embed_texts(client, [instructed_query], config))[0]
return embed_texts([instructed_query], config)[0]
async def ensure_embedding_models(session: AsyncSession) -> None:
def ensure_embedding_models(session: Session) -> None:
"""Ensure supported embedding model rows exist."""
for name, dimension in MODEL_DIMENSIONS.items():
existing = await session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == name))
existing = session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == name))
if existing is None:
session.add(EbookEmbeddingModel(name=name, dimension=dimension, is_default=name == "qwen3-embedding-0.6b"))
logger.info(f"ebook_embedding_model_created {name=} {dimension=}")
await session.flush()
logger.info("ebook_embedding_model_created model=%s dimension=%s", name, dimension)
session.flush()
async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelStats]:
def embedding_model_stats(session: Session) -> list[EmbeddingModelStats]:
"""Return embedding coverage counts for every supported model."""
total_chunks = await session.scalar(select(func.count(EbookChunk.id))) or 0
total_chunks = session.scalar(select(func.count(EbookChunk.id))) or 0
models = {
model.name: model
for model in await session.scalars(
for model in session.scalars(
select(EbookEmbeddingModel)
.where(EbookEmbeddingModel.name.in_(MODEL_DIMENSIONS))
.order_by(EbookEmbeddingModel.name)
@@ -117,7 +122,7 @@ async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelSta
embedded_chunks = 0
if model is not None:
table = get_embedding_table(dimension)
embedded_chunks = await session.scalar(select(func.count(table.id)).where(table.model_id == model.id)) or 0
embedded_chunks = session.scalar(select(func.count(table.id)).where(table.model_id == model.id)) or 0
stats.append(
EmbeddingModelStats(
model_name=model_name,
@@ -129,10 +134,10 @@ async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelSta
return stats
async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient, config: EbookSearchConfig) -> int:
def embed_missing_chunks(session: Session, config: EbookSearchConfig) -> int:
"""Embed chunks missing embeddings for the configured model."""
await ensure_embedding_models(session)
model = await session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model))
ensure_embedding_models(session)
model = session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model))
if model is None:
supported_models = ", ".join(MODEL_DIMENSIONS)
msg = f"Unknown embedding model: {config.embedding_model}. Supported models: {supported_models}"
@@ -140,7 +145,7 @@ async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient,
table = get_embedding_table(model.dimension)
chunks = list(
await session.scalars(
session.scalars(
select(EbookChunk)
.outerjoin(table, (table.chunk_id == EbookChunk.id) & (table.model_id == model.id))
.where(table.id.is_(None))
@@ -149,17 +154,17 @@ async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient,
)
)
if not chunks:
logger.info(f"ebook_embed_missing_none {config.embedding_model=}")
logger.info("ebook_embed_missing_none model=%s", config.embedding_model)
return 0
logger.info(f"ebook_embed_missing_batch_start {config.embedding_model=} count={len(chunks)}")
vectors = await embed_texts(client, [chunk.text for chunk in chunks], config)
logger.info("ebook_embed_missing_batch_start model=%s count=%s", config.embedding_model, len(chunks))
vectors = embed_texts([chunk.text for chunk in chunks], config)
rows = [
{"chunk_id": chunk.id, "model_id": model.id, "embedding": vector}
for chunk, vector in zip(chunks, vectors, strict=True)
]
statement = insert(table).values(rows).on_conflict_do_nothing(index_elements=["chunk_id", "model_id"])
await session.execute(statement)
await session.flush()
logger.info(f"ebook_embed_missing_batch_complete {config.embedding_model=} count={len(rows)}")
session.execute(statement)
session.flush()
logger.info("ebook_embed_missing_batch_complete model=%s count=%s", config.embedding_model, len(rows))
return len(rows)
+68 -91
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
@@ -14,7 +13,6 @@ import tiktoken
from sqlalchemy import or_, select
from python.ebook_search.epub_parse import parse_epub
from python.ebook_search.protected_phrases.matching import index_chunk_phrase_mentions_for_book
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
logger = logging.getLogger(__name__)
@@ -22,7 +20,7 @@ DEFAULT_CHUNK_TOKENS = 700
DEFAULT_CHUNK_OVERLAP = 100
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.epub_parse import ParsedChapter
@@ -74,111 +72,90 @@ def chunk_text(
return [chunk for chunk in chunks if chunk.text]
def find_library_epubs(library_path: str) -> tuple[Path, list[Path] | None]:
"""Resolve one configured library path and collect its EPUB files (blocking filesystem walk).
Returns:
tuple[Path, list[Path] | None]: The expanded path and its EPUB files, or ``None`` when
the path is neither an EPUB file nor a directory.
"""
path = Path(library_path).expanduser()
if path.is_file() and path.suffix.lower() == ".epub":
return path, [path]
if path.is_dir():
return path, sorted(path.rglob("*.epub"))
return path, None
async def ingest_configured_paths(session: AsyncSession, config: EbookSearchConfig) -> int:
def ingest_configured_paths(session: Session, config: EbookSearchConfig) -> int:
"""Ingest every EPUB found under configured library paths."""
count = 0
for library_path in config.library_paths:
path, epub_paths = await asyncio.to_thread(find_library_epubs, library_path)
logger.info(f"ebook_ingest_path_start {path=}")
if epub_paths is None:
logger.warning(f"ebook_ingest_path_missing {path=}")
continue
for epub_path in epub_paths:
count += int(await ingest_file(session, epub_path, config))
logger.info(f"ebook_ingest_paths_complete {count=} configured_paths={len(config.library_paths)}")
path = Path(library_path).expanduser()
logger.info("ebook_ingest_path_start path=%s", path)
if path.is_file() and path.suffix.lower() == ".epub":
count += int(ingest_file(session, path, config))
elif path.is_dir():
for epub_path in sorted(path.rglob("*.epub")):
count += int(ingest_file(session, epub_path, config))
else:
logger.warning("ebook_ingest_path_missing path=%s", path)
logger.info("ebook_ingest_paths_complete changed_files=%s configured_paths=%s", count, len(config.library_paths))
return count
def resolve_ingest_path(path: Path) -> Path:
"""Expand and resolve an ingest path (blocking filesystem call)."""
return path.expanduser().resolve()
async def ingest_file(session: AsyncSession, path: Path, config: EbookSearchConfig) -> bool:
def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool:
"""Ingest one EPUB file. Return True when the database changed."""
try:
resolved_path = await asyncio.to_thread(resolve_ingest_path, path)
logger.info(f"ebook_ingest_file_start {resolved_path=}")
file_hash = await asyncio.to_thread(sha256_file, resolved_path)
existing = await find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = resolved_path.stat()
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
await session.flush()
logger.info(f"ebook_ingest_file_unchanged {existing.id=} {resolved_path=}")
return False
if existing is not None:
logger.info(f"ebook_ingest_file_replacing {existing.id=} {resolved_path=}")
await session.delete(existing)
await session.flush()
resolved_path = path.expanduser().resolve()
logger.info("ebook_ingest_file_start path=%s", resolved_path)
file_hash = sha256_file(resolved_path)
existing = find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = resolved_path.stat()
parsed = await asyncio.to_thread(parse_epub, resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
language=parsed.language,
publisher=parsed.publisher,
identifier=parsed.identifier,
file_path=str(resolved_path),
file_sha256=file_hash,
file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
)
session.add(source)
await session.flush()
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
chapter = EbookChapter(
source_id=source.id,
spine_index=spine_index,
title=parsed_chapter.title,
href=parsed_chapter.href,
)
session.add(chapter)
await session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
await session.commit()
mention_count = await index_chunk_phrase_mentions_for_book(session, source.id, config)
logger.info(
f"ebook_ingest_file_complete {source.id=} {resolved_path=} chapters={len(parsed.chapters)} {chunk_index=} "
f"{mention_count=}"
)
except Exception:
logger.exception(f"ebook_ingest_file_error {path=}")
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
session.flush()
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path)
return False
else:
return True
if existing is not None:
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path)
session.delete(existing)
session.flush()
stat = resolved_path.stat()
parsed = parse_epub(resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
language=parsed.language,
publisher=parsed.publisher,
identifier=parsed.identifier,
file_path=str(resolved_path),
file_sha256=file_hash,
file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
)
session.add(source)
session.flush()
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
chapter = EbookChapter(
source_id=source.id,
spine_index=spine_index,
title=parsed_chapter.title,
href=parsed_chapter.href,
)
session.add(chapter)
session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
session.flush()
logger.info(
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
source.id,
resolved_path,
len(parsed.chapters),
chunk_index,
)
return True
async def find_existing_source(session: AsyncSession, path: Path, file_hash: str) -> EbookSource | None:
def find_existing_source(session: Session, path: Path, file_hash: str) -> EbookSource | None:
"""Find an existing source by canonical path or file hash."""
return await session.scalar(
return session.scalar(
select(EbookSource).where(or_(EbookSource.file_path == str(path), EbookSource.file_sha256 == file_hash))
)
def add_chapter_chunks(
session: AsyncSession,
session: Session,
source: EbookSource,
chapter: EbookChapter,
parsed_chapter: ParsedChapter,
+33 -94
View File
@@ -22,26 +22,10 @@ def auth_headers(api_key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
async def request_embeddings(
client: httpx.AsyncClient,
texts: Sequence[str],
config: EbookSearchConfig,
) -> list[list[float]]:
"""Request embeddings from the configured OpenAI-compatible endpoint.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
texts (Sequence[str]): Texts to embed.
config (EbookSearchConfig): Runtime settings supplying the endpoint, model, and auth.
Returns:
list[list[float]]: One embedding vector per input text.
Raises:
RuntimeError: If the request fails or the response cannot be parsed.
"""
def request_embeddings(texts: Sequence[str], config: EbookSearchConfig) -> list[list[float]]:
"""Request embeddings from the configured OpenAI-compatible endpoint."""
try:
response = await client.post(
response = httpx.post(
f"{config.embedding_base_url.rstrip('/')}/embeddings",
headers=auth_headers(config.embedding_api_key),
json={"model": config.embedding_model, "input": list(texts)},
@@ -51,62 +35,41 @@ async def request_embeddings(
return embedding_vectors_from_response(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
logger.exception(
f"ebook_embed_request_failed {config.embedding_base_url=} {config.embedding_model=} count={len(texts)}"
"ebook_embed_request_failed base_url=%s model=%s count=%s",
config.embedding_base_url,
config.embedding_model,
len(texts),
)
msg = f"Embedding request failed. base_url={config.embedding_base_url} model={config.embedding_model}"
raise RuntimeError(msg) from error
async def check_embedding_endpoint(
client: httpx.AsyncClient,
config: EbookSearchConfig,
*,
timeout_seconds: float = 5.0,
) -> bool:
def check_embedding_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured embedding endpoint answers a model listing."""
return await _check_endpoint(
client,
base_url=config.embedding_base_url,
api_key=config.embedding_api_key,
timeout_seconds=timeout_seconds,
unavailable_log=f"ebook_embedding_endpoint_unreachable {config.embedding_base_url=}",
)
async def check_chat_endpoint(
client: httpx.AsyncClient,
config: EbookSearchConfig,
*,
timeout_seconds: float = 5.0,
) -> bool:
"""Return whether the configured chat (answering) endpoint answers a model listing."""
return await _check_endpoint(
client,
base_url=config.vllm_base_url,
api_key=config.vllm_api_key,
timeout_seconds=timeout_seconds,
unavailable_log=f"ebook_chat_endpoint_unreachable {config.vllm_base_url=}",
)
async def _check_endpoint(
client: httpx.AsyncClient,
*,
base_url: str,
api_key: str,
timeout_seconds: float,
unavailable_log: str,
) -> bool:
"""Return whether an OpenAI-compatible endpoint answers a model listing."""
try:
response = await client.get(
f"{base_url.rstrip('/')}/models",
headers=auth_headers(api_key),
response = httpx.get(
f"{config.embedding_base_url.rstrip('/')}/models",
headers=auth_headers(config.embedding_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning(f"{unavailable_log} {error=}")
logger.warning("ebook_embedding_endpoint_unreachable base_url=%s error=%s", config.embedding_base_url, error)
return False
return True
def check_chat_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured chat (answering) endpoint answers a model listing."""
try:
response = httpx.get(
f"{config.vllm_base_url.rstrip('/')}/models",
headers=auth_headers(config.vllm_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning("ebook_chat_endpoint_unreachable base_url=%s error=%s", config.vllm_base_url, error)
return False
return True
@@ -135,29 +98,18 @@ def embedding_vectors_from_response(body: object) -> list[list[float]]:
return vectors
async def request_rerank(
client: httpx.AsyncClient,
def request_rerank(
query: str,
documents: Sequence[str],
config: RerankConfig,
) -> object | None:
"""Request rerank scores from the configured vLLM endpoint.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
query (str): Query the documents are scored against.
documents (Sequence[str]): Candidate documents to score.
config (RerankConfig): Rerank endpoint settings.
Returns:
object | None: The decoded response body, or ``None`` when it is not valid JSON.
"""
"""Request rerank scores from the configured vLLM endpoint."""
payload = {
"model": config.model,
"query": query,
"documents": list(documents),
}
response = await client.post(
response = httpx.post(
f"{config.base_url.rstrip('/')}/rerank",
json=payload,
timeout=config.timeout_seconds,
@@ -170,26 +122,13 @@ async def request_rerank(
return None
async def request_chat_completion(
client: httpx.AsyncClient,
def request_chat_completion(
config: EbookSearchConfig,
messages: Sequence[dict[str, str]],
) -> str:
"""Request a chat completion over a shared async client.
Args:
client (httpx.AsyncClient): Shared async client whose connection pool bounds concurrency.
config (EbookSearchConfig): Runtime settings supplying the endpoint, model, and auth.
messages (Sequence[dict[str, str]]): OpenAI-style chat messages.
Returns:
str: The assistant message text.
Raises:
RuntimeError: If the request fails or the response cannot be parsed.
"""
"""Request a chat completion from the configured OpenAI-compatible endpoint."""
try:
response = await client.post(
response = httpx.post(
f"{config.vllm_base_url.rstrip('/')}/chat/completions",
headers=auth_headers(config.vllm_api_key),
json={
+9 -2
View File
@@ -112,7 +112,7 @@ async def send_search(client: httpx.AsyncClient, query: str, *, rerank: bool) ->
try:
response = await client.post("/search", data=data)
except httpx.HTTPError as error:
logger.warning(f"ebook_loadtest_request_failed {error=}")
logger.warning("ebook_loadtest_request_failed error=%s", error)
return RequestResult(status_code=0, latency_ms=(time.perf_counter() - start) * 1000, ok=False)
return RequestResult(
status_code=response.status_code,
@@ -192,7 +192,14 @@ def main(
"""Load test the search endpoint and report latency and throughput."""
configure_logger(log_level)
queries = load_queries(queries_file)
logger.info(f"ebook_loadtest_start {base_url=} {request_count=} {concurrency=} {rerank=} queries={len(queries)}")
logger.info(
"ebook_loadtest_start base_url=%s requests=%s concurrency=%s rerank=%s queries=%s",
base_url,
request_count,
concurrency,
rerank,
len(queries),
)
summary = asyncio.run(
run_load(
base_url=base_url,
@@ -1 +0,0 @@
"""Protected phrase extraction and matching for ebook search."""
@@ -1,17 +0,0 @@
"""Protected phrase extraction, storage, and runtime matching."""
from python.ebook_search.protected_phrases.config.lib import (
get_bad_ends,
get_bad_starts,
get_ignored_phrases,
get_junk_tokens,
get_most_common_words,
)
__all__ = [
"get_bad_ends",
"get_bad_starts",
"get_ignored_phrases",
"get_junk_tokens",
"get_most_common_words",
]
@@ -1,31 +0,0 @@
tokens = [
"a",
"an",
"and",
"any",
"as",
"at",
"be",
"because",
"but",
"by",
"can",
"could",
"do",
"for",
"from",
"have",
"if",
"of",
"or",
"some",
"than",
"the",
"these",
"this",
"to",
"will",
"with",
"would",
"did",
]
@@ -1,27 +0,0 @@
tokens = [
"a",
"an",
"did",
"didn't",
"he",
"here",
"how",
"i",
"it",
"she",
"that",
"the",
"there",
"they",
"this",
"we",
"what",
"when",
"where",
"which",
"who",
"whom",
"whose",
"why",
"you",
]
@@ -1,212 +0,0 @@
phrases = [
"a little",
"across the",
"and she",
"anyone in",
"are you",
"around him",
"around the",
"as much",
"as soon",
"at all",
"at least",
"before the",
"behind him",
"between the",
"but she",
"could not",
"did he",
"did i",
"did it",
"did not believe",
"did not care",
"did not even",
"did not know what",
"did not know",
"did not like",
"did not look",
"did not mean",
"did not move",
"did not need",
"did not see",
"did not seem",
"did not think",
"did not understand",
"did not want",
"did not",
"did she",
"did so",
"did that",
"did the",
"did they",
"did what",
"did you",
"didn't answer",
"didn't care",
"didn't even",
"didn't expect",
"didn't feel",
"didn't get",
"didn't i",
"didn't know",
"didn't like",
"didn't look",
"didn't make",
"didn't mean",
"didn't need",
"didn't really",
"didn't say",
"didn't see",
"didn't seem",
"didn't think",
"didn't want",
"didn't you",
"end up",
"ended up",
"had a",
"had been",
"have been",
"he asked",
"he concluded",
"he continued",
"he couldn't",
"he did",
"he didn't",
"he felt",
"he had",
"he hadn't",
"he knew",
"he noted",
"he pointed",
"he realized",
"he replied",
"he said",
"he saw",
"he tapped",
"he told",
"he was",
"he wasn't",
"his body",
"his chair",
"his feet",
"his hands",
"his head",
"his office",
"his own",
"his pc",
"his power",
"his shield",
"his sight",
"his voice",
"his wrist",
"how many",
"i am",
"i don't",
"i said",
"i was",
"i wouldn't",
"i'm not",
"if he",
"if they",
"is in",
"is not",
"is that",
"is the",
"it had",
"it had",
"it is",
"it was",
"it wasn't",
"it wasn't",
"no one",
"of course",
"of force",
"of it",
"of magic",
"of marines",
"of power",
"of those",
"old man",
"older man",
"one of",
"out of",
"set up",
"she admitted",
"she asked",
"she had",
"she replied",
"she said",
"she snapped",
"she told",
"she was",
"she'd been",
"shook his",
"sure he",
"tell you",
"that had",
"that is",
"that she",
"that was",
"the dark",
"the door",
"the first",
"the last",
"the man",
"the one",
"the only",
"the other",
"the rest",
"the room",
"the same",
"the two",
"the way",
"the world",
"there are",
"there was",
"there were",
"they are",
"they had",
"they were",
"they weren't",
"this is",
"this place",
"though he",
"through his",
"through the",
"to find",
"to get",
"to keep",
"to stay",
"to stop",
"to tell",
"to try",
"told her",
"told him",
"under his",
"was a",
"was enough",
"was going",
"was in",
"was no",
"was not",
"was now",
"was on",
"was one",
"was only",
"was still",
"was that",
"was the",
"was there",
"were in",
"what had",
"what happened",
"what was",
"where the",
"while i",
"you are",
"you can't",
"you don't",
"you know",
"you need",
"you were",
]
@@ -1,71 +0,0 @@
tokens = [
"said",
"asked",
"replied",
"answered",
"looked",
"nodded",
"turned",
"shook",
"smiled",
"shrugged",
"pointed",
"continued",
"repeated",
"stared",
"agreed",
"glanced",
"walked",
"told",
"thought",
"knew",
"wanted",
"muttered",
"whispered",
"laughed",
"sighed",
"paused",
"gestured",
"waved",
"frowned",
"grinned",
"admitted",
"found",
"noted",
"murmured",
"ordered",
"i'm",
"i've",
"i'd",
"i'll",
"it's",
"that's",
"don't",
"didn't",
"doesn't",
"can't",
"won't",
"wouldn't",
"couldn't",
"shouldn't",
"isn't",
"wasn't",
"aren't",
"weren't",
"you're",
"you've",
"you'll",
"we're",
"we've",
"we'll",
"they're",
"they've",
"he's",
"she's",
"there's",
"what's",
"let's",
"who's",
"he'd",
"she'd",
]
@@ -1,60 +0,0 @@
"""Protected phrase extraction, storage, and runtime matching."""
from __future__ import annotations
import logging
import tomllib
from functools import cache
from pathlib import Path
from python.ebook_search.protected_phrases.text_normalization import normalize_text
logger = logging.getLogger(__name__)
def _load_toml_string_set(path: Path, key: str) -> frozenset[str]:
"""Load and validate a TOML string list as a normalized immutable set."""
with path.open("rb") as file:
body = tomllib.load(file)
values = body.get(key)
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
msg = f"{path} must contain a {key!r} string list"
raise ValueError(msg)
return frozenset(normalize_text(value) for value in values if normalize_text(value))
@cache
def _get_phrase_config_dir() -> Path:
"""Return the directory containing phrase configuration files."""
return Path(__file__).resolve().parent
@cache
def get_ignored_phrases() -> frozenset[str]:
"""Return ignored phrase strings loaded from TOML."""
return _load_toml_string_set(_get_phrase_config_dir() / "ignored_phrases.toml", "phrases")
@cache
def get_bad_ends() -> frozenset[str]:
"""Return bad phrase-ending tokens loaded from TOML."""
return _load_toml_string_set(_get_phrase_config_dir() / "bad_ends.toml", "tokens")
@cache
def get_bad_starts() -> frozenset[str]:
"""Return bad phrase-starting tokens loaded from TOML."""
return _load_toml_string_set(_get_phrase_config_dir() / "bad_starts.toml", "tokens")
@cache
def get_most_common_words() -> frozenset[str]:
"""Return the most common English words loaded from TOML."""
return _load_toml_string_set(_get_phrase_config_dir() / "most_common_words.toml", "words")
@cache
def get_junk_tokens() -> frozenset[str]:
"""Return junk tokens (dialogue verbs and pronoun contractions) loaded from TOML."""
return _load_toml_string_set(_get_phrase_config_dir() / "junk_tokens.toml", "tokens")
@@ -1,102 +0,0 @@
words = [
"the",
"be",
"to",
"of",
"and",
"a",
"in",
"that",
"have",
"I",
"it",
"for",
"not",
"on",
"with",
"he",
"as",
"you",
"do",
"at",
"this",
"but",
"his",
"by",
"from",
"they",
"we",
"say",
"her",
"she",
"or",
"an",
"will",
"my",
"one",
"all",
"would",
"there",
"their",
"what",
"so",
"up",
"out",
"if",
"about",
"who",
"get",
"which",
"go",
"me",
"when",
"make",
"can",
"like",
"time",
"no",
"just",
"him",
"know",
"take",
"people",
"into",
"year",
"your",
"good",
"some",
"could",
"them",
"see",
"other",
"than",
"then",
"now",
"look",
"only",
"come",
"its",
"over",
"think",
"also",
"back",
"after",
"use",
"two",
"how",
"our",
"work",
"first",
"well",
"way",
"even",
"new",
"want",
"because",
"any",
"these",
"give",
"day",
"most",
"us",
]
@@ -1,725 +0,0 @@
"""Candidate phrase extraction and scoring for protected phrases."""
from __future__ import annotations
import logging
import re
from collections import Counter, defaultdict
from functools import lru_cache
from time import perf_counter
from typing import TYPE_CHECKING, Protocol
from yake import KeywordExtractor
from python.ebook_search.protected_phrases.config import (
get_bad_ends,
get_bad_starts,
get_ignored_phrases,
get_junk_tokens,
get_most_common_words,
)
from python.ebook_search.protected_phrases.models import PhraseCandidate
from python.ebook_search.protected_phrases.text_normalization import tokenize, tokenize_with_offsets
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
from python.ebook_search.config import EbookSearchConfig
logger = logging.getLogger(__name__)
BAD_START_SCORE_PENALTY = 10.0
BAD_END_SCORE_PENALTY = 10.0
MULTI_SOURCE_SCORE_BONUS = 2.0
MULTI_SOURCE_MIN_SOURCES = 2
CAPITALIZED_PHRASE_RE = re.compile(r"\b(?:[A-Z][a-zA-Z']+)(?:\s+(?:of|the|and|in|on|for|[A-Z][a-zA-Z']+)){0,6}")
class YakeExtractor(Protocol):
"""Small protocol for the YAKE extractor used by this module."""
def extract_keywords(self, text: str) -> Iterable[tuple[str, float]]:
"""Return YAKE keyword tuples."""
class YakeExtractorFactory(Protocol):
"""Callable constructor protocol for YAKE keyword extractors."""
def __call__(self, *, lan: str, n: int, dedupLim: float, top: int) -> YakeExtractor: # noqa: N803
"""Create a YAKE keyword extractor.
Args:
lan (str): Language code passed to YAKE.
n (int): Maximum n-gram size to extract.
dedupLim (float): Deduplication similarity threshold.
top (int): Maximum number of keyphrases to return.
Returns:
YakeExtractor: The constructed keyword extractor.
"""
def normalize_candidate_phrase(
phrase_text: str,
config: EbookSearchConfig,
*,
max_tokens: int | None = None,
) -> tuple[str, str, int] | None:
"""Normalize a candidate phrase and validate token bounds.
Args:
phrase_text (str): Raw phrase text to normalize.
config (EbookSearchConfig): Runtime phrase-tuning settings.
max_tokens (int | None): Maximum token count override; defaults to ``config.phrase_max_tokens``.
Returns:
tuple[str, str, int] | None: Display text, normalized phrase, and token count, or ``None``
when the phrase falls outside the token bounds or is ignored.
"""
normalized_tokens = tokenize_with_offsets(phrase_text)
max_count = config.phrase_max_tokens if max_tokens is None else max_tokens
if len(normalized_tokens) < config.phrase_min_tokens or len(normalized_tokens) > max_count:
return None
phrase_norm = " ".join(token.text for token in normalized_tokens)
if phrase_norm in get_ignored_phrases():
return None
display_text = phrase_text[normalized_tokens[0].start_char : normalized_tokens[-1].end_char].strip()
return display_text or phrase_norm, phrase_norm, len(normalized_tokens)
def count_raw_ngrams(tokens: Sequence[str], config: EbookSearchConfig) -> Counter[str]:
"""Count every n-gram window in one normalized token block.
``tokens`` are already normalized (see :func:`tokenize`), so each window's normalized form
is the joined tokens directly. Counting into a plain :class:`Counter` rather than
:class:`PhraseCandidate` objects keeps this hot loop cheap; callers filter ignored phrases
and materialize candidates per unique phrase afterwards, which is far fewer operations than
doing either per window.
Args:
tokens (Sequence[str]): Normalized tokens for one text block.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
Counter[str]: Raw occurrence counts keyed by normalized phrase.
"""
return Counter(
" ".join(tokens[start : start + ngram_size])
for ngram_size in range(config.phrase_min_tokens, config.phrase_max_tokens + 1)
for start in range(len(tokens) - ngram_size + 1)
)
def extract_raw_ngrams_by_chapter(
chapters: Sequence[str],
config: EbookSearchConfig,
) -> dict[str, PhraseCandidate]:
"""Extract raw n-grams across chapters, tracking both raw counts and chapter spread.
Counting each chapter separately makes chapter spread fall out of dict membership: a phrase's
``chapter_count`` is simply how many per-chapter count maps contain it, so no per-window seen
tracking is needed. This also lets the enrichment step skip re-sliding the same n-gram sizes.
Phrases below the minimum raw count are dropped here rather than materialized: most unique
n-grams occur once, and :func:`filter_storable_candidates` would discard them as too rare
anyway, so building ``PhraseCandidate`` objects for them is wasted work.
Args:
chapters (Sequence[str]): Chapter-like text blocks to slide n-gram windows over.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
dict[str, PhraseCandidate]: Candidates meeting the minimum raw count, keyed by normalized
phrase, with raw and chapter counts.
"""
chapter_count_maps = [count_raw_ngrams(tokenize(chapter), config) for chapter in chapters]
total_counts: Counter[str] = Counter()
chapter_spread: Counter[str] = Counter()
for chapter_counts in chapter_count_maps:
total_counts.update(chapter_counts)
chapter_spread.update(chapter_counts.keys())
min_raw_count = minimum_candidate_raw_count(config)
ignored = get_ignored_phrases()
return {
phrase_norm: PhraseCandidate(
phrase_text=phrase_norm,
phrase_norm=phrase_norm,
token_count=phrase_norm.count(" ") + 1,
source_raw_ngram=True,
raw_count=raw_count,
chapter_count=chapter_spread[phrase_norm],
)
for phrase_norm, raw_count in total_counts.items()
if raw_count >= min_raw_count and phrase_norm not in ignored
}
@lru_cache(maxsize=2)
def get_yake_extractor(max_ngram: int, top_k: int) -> KeywordExtractor:
"""Return a cached YAKE extractor for the given settings.
Constructing a ``KeywordExtractor`` loads the language's stopword list from disk, so it is
cached and reused across books rather than rebuilt on every call.
Args:
max_ngram (int): Maximum n-gram size to extract.
top_k (int): Maximum number of keyphrases to request.
Returns:
KeywordExtractor: A shared extractor instance for the given settings.
"""
return KeywordExtractor(lan="en", n=max_ngram, dedupLim=0.85, top=top_k)
def extract_yake_candidates(
book_text: str,
config: EbookSearchConfig,
top_k: int = 1000,
) -> dict[str, PhraseCandidate]:
"""Extract YAKE keyphrases when the optional YAKE package is installed.
Args:
book_text (str): Full book text to extract keyphrases from.
config (EbookSearchConfig): Runtime phrase-tuning settings.
top_k (int): Maximum number of YAKE keyphrases to request.
Returns:
dict[str, PhraseCandidate]: Candidates keyed by normalized phrase, with YAKE scores.
"""
extractor = get_yake_extractor(config.phrase_max_tokens, top_k)
out: dict[str, PhraseCandidate] = {}
for phrase_text, yake_score in extractor.extract_keywords(book_text):
normalized = normalize_candidate_phrase(phrase_text, config)
if normalized is None:
continue
display_text, phrase_norm, token_count = normalized
out[phrase_norm] = PhraseCandidate(
phrase_text=display_text,
phrase_norm=phrase_norm,
token_count=token_count,
source_yake=True,
yake_score=float(yake_score),
)
return out
def extract_capitalized_phrases(original_text: str, config: EbookSearchConfig) -> dict[str, PhraseCandidate]:
"""Extract capitalized phrase runs that often carry fictional terms.
Args:
original_text (str): Original-case book text to scan for capitalized runs.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
dict[str, PhraseCandidate]: Candidates keyed by normalized phrase from capitalized runs.
"""
out: dict[str, PhraseCandidate] = {}
for match in CAPITALIZED_PHRASE_RE.finditer(original_text):
phrase_text = match.group(0).strip()
normalized = normalize_candidate_phrase(
phrase_text,
config,
max_tokens=config.phrase_max_entity_tokens,
)
if normalized is None:
continue
display_text, phrase_norm, token_count = normalized
out[phrase_norm] = PhraseCandidate(
phrase_text=display_text,
phrase_norm=phrase_norm,
token_count=token_count,
source_capitalized=True,
)
return out
def extract_metadata_candidates(
metadata: Mapping[str, object] | None,
config: EbookSearchConfig,
) -> dict[str, PhraseCandidate]:
"""Extract phrases from book metadata values such as title, author, and series.
Args:
metadata (Mapping[str, object] | None): Book metadata values, or ``None`` when unavailable.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
dict[str, PhraseCandidate]: Candidates keyed by normalized phrase from metadata values.
"""
if metadata is None:
return {}
out: dict[str, PhraseCandidate] = {}
for value in metadata.values():
if value is None:
continue
phrase_text = str(value).strip()
normalized = normalize_candidate_phrase(
phrase_text,
config,
max_tokens=config.phrase_max_entity_tokens,
)
if normalized is None:
continue
display_text, phrase_norm, token_count = normalized
out[phrase_norm] = PhraseCandidate(
phrase_text=display_text,
phrase_norm=phrase_norm,
token_count=token_count,
source_metadata=True,
)
return out
def merge_candidate_sources(*sources: Mapping[str, PhraseCandidate]) -> dict[str, PhraseCandidate]:
"""Merge candidate dictionaries by normalized phrase.
Args:
*sources (Mapping[str, PhraseCandidate]): Candidate maps to combine, keyed by normalized phrase.
Returns:
dict[str, PhraseCandidate]: One merged candidate per normalized phrase.
"""
merged: dict[str, PhraseCandidate] = {}
for source in sources:
for phrase_norm, item in source.items():
existing = merged.setdefault(
phrase_norm,
PhraseCandidate(
phrase_text=item.phrase_text,
phrase_norm=phrase_norm,
token_count=item.token_count,
),
)
merge_candidate(existing, item)
return merged
def merge_candidate(existing: PhraseCandidate, item: PhraseCandidate) -> None:
"""Merge one candidate into an existing candidate object.
Args:
existing (PhraseCandidate): Candidate mutated in place to absorb ``item``.
item (PhraseCandidate): Candidate whose sources, counts, and scores are merged in.
"""
existing.source_raw_ngram = existing.source_raw_ngram or item.source_raw_ngram
existing.source_yake = existing.source_yake or item.source_yake
existing.source_capitalized = existing.source_capitalized or item.source_capitalized
existing.source_metadata = existing.source_metadata or item.source_metadata
existing.raw_count += item.raw_count
existing.chapter_count = max(existing.chapter_count, item.chapter_count)
if item.yake_score is not None:
existing.yake_score = item.yake_score
def enrich_with_frequency_and_chapter_counts(
candidates: Mapping[str, PhraseCandidate],
chapters: Sequence[str],
*,
counted_sizes: Iterable[int] = (),
) -> dict[str, PhraseCandidate]:
"""Add raw occurrence and chapter-spread counts to candidates.
Candidates whose ``token_count`` is in ``counted_sizes`` are left untouched: those counts
were already computed while sliding the chapters in :func:`extract_raw_ngrams_by_chapter`,
so re-sliding those n-gram sizes here would just duplicate that work.
Args:
candidates (Mapping[str, PhraseCandidate]): Candidates to enrich, keyed by normalized phrase.
chapters (Sequence[str]): Chapter-like text blocks used to count occurrences and spread.
counted_sizes (Iterable[int]): Token counts whose counts are already populated and should be skipped.
Returns:
dict[str, PhraseCandidate]: Candidates with updated ``raw_count`` and ``chapter_count`` values.
"""
if not candidates:
return {}
already_counted = set(counted_sizes)
candidate_sets_by_size: dict[int, set[str]] = defaultdict(set)
for phrase_norm, candidate in candidates.items():
if candidate.token_count in already_counted:
continue
candidate_sets_by_size[candidate.token_count].add(phrase_norm)
enriched = dict(candidates)
if not candidate_sets_by_size:
return enriched
total_counts, chapter_counts = count_candidate_occurrences(candidate_sets_by_size, chapters)
for phrase_norm, candidate in enriched.items():
if candidate.token_count in already_counted:
continue
candidate.raw_count = max(candidate.raw_count, total_counts[phrase_norm])
candidate.chapter_count = chapter_counts[phrase_norm]
return enriched
def count_candidate_occurrences(
candidate_sets_by_size: Mapping[int, set[str]],
chapters: Sequence[str],
) -> tuple[dict[str, int], dict[str, int]]:
"""Count total occurrences and chapter spread for candidate phrases across chapters.
Args:
candidate_sets_by_size (Mapping[int, set[str]]): Candidate normalized phrases grouped by token count.
chapters (Sequence[str]): Chapter-like text blocks to slide n-gram windows over.
Returns:
tuple[dict[str, int], dict[str, int]]: Total occurrence counts and chapter-spread counts,
each keyed by normalized phrase.
"""
total_counts: defaultdict[str, int] = defaultdict(int)
chapter_counts: defaultdict[str, int] = defaultdict(int)
for chapter in chapters:
seen_in_chapter: set[str] = set()
chapter_tokens = tokenize(chapter)
for ngram_size, candidate_norms in candidate_sets_by_size.items():
for start in range(len(chapter_tokens) - ngram_size + 1):
phrase_norm = " ".join(chapter_tokens[start : start + ngram_size])
if phrase_norm not in candidate_norms:
continue
total_counts[phrase_norm] += 1
seen_in_chapter.add(phrase_norm)
for phrase_norm in seen_in_chapter:
chapter_counts[phrase_norm] += 1
return total_counts, chapter_counts
def filter_storable_candidates(
candidates: Mapping[str, PhraseCandidate],
config: EbookSearchConfig,
) -> tuple[dict[str, PhraseCandidate], int, int, int, int]:
"""Remove candidates that should not be persisted.
Args:
candidates (Mapping[str, PhraseCandidate]): Candidates to filter, keyed by normalized phrase.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
tuple[dict[str, PhraseCandidate], int, int, int, int]: The storable candidates followed by the
counts dropped for being too short, too rare, too common, and junk.
"""
min_raw_count = minimum_candidate_raw_count(config)
filtered: dict[str, PhraseCandidate] = {}
too_short = 0
too_rare = 0
too_common = 0
junk = 0
for phrase_norm, candidate in candidates.items():
if candidate.token_count < config.phrase_min_tokens:
too_short += 1
continue
if candidate.raw_count < min_raw_count:
too_rare += 1
continue
phrase_tokens = phrase_norm.split()
if is_most_common_word_phrase(phrase_tokens):
too_common += 1
continue
if is_junk_phrase(phrase_tokens):
junk += 1
continue
filtered[phrase_norm] = candidate
return filtered, too_short, too_rare, too_common, junk
def minimum_candidate_raw_count(config: EbookSearchConfig) -> int:
"""Return the minimum occurrence count required before storing a candidate.
Args:
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
int: The minimum raw occurrence count, never less than 1.
"""
return max(config.phrase_raw_ngram_min_count, 1)
def is_most_common_word_phrase(phrase_tokens: list[str]) -> bool:
"""Return whether every token in a normalized phrase is a common word.
Args:
phrase_tokens (list[str]): Normalized phrase tokens to inspect.
Returns:
bool: True when the phrase is non-empty and every token is a common word.
"""
common_words = get_most_common_words()
return bool(phrase_tokens) and all(token in common_words for token in phrase_tokens)
def is_junk_phrase(phrase_tokens: list[str]) -> bool:
"""Return whether a normalized phrase is lexical junk not worth LLM judging.
Judged data shows phrases containing a dialogue/action verb or a pronoun contraction are
never kept, and phrases whose tokens are mostly common words almost never are. Possessives
of proper nouns (``chapman's death``) pass because matching is by exact token, and
exactly-half-common bigrams (``data feed``) pass because the common-word rule is strict.
Args:
phrase_tokens (list[str]): Normalized phrase tokens to inspect.
Returns:
bool: True when the phrase contains a junk token or is majority common words.
"""
if not phrase_tokens:
return False
junk_tokens = get_junk_tokens()
if any(token in junk_tokens for token in phrase_tokens):
return True
common_words = get_most_common_words()
half_phrase_len = len(phrase_tokens) // 2
return sum(token in common_words for token in phrase_tokens) > half_phrase_len
def score_candidate(candidate: PhraseCandidate, config: EbookSearchConfig) -> float:
"""Score a phrase candidate before LLM judging.
Args:
candidate (PhraseCandidate): Candidate to score.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
float: Combined score from sources, frequency, and length, less any penalties.
"""
score = source_score(candidate) + frequency_score(candidate, config) + token_count_score(candidate, config)
if non_raw_source_count(candidate) >= MULTI_SOURCE_MIN_SOURCES:
score += MULTI_SOURCE_SCORE_BONUS
if candidate.phrase_norm in get_ignored_phrases():
score -= 100.0
if has_bad_start(candidate.phrase_norm):
score -= BAD_START_SCORE_PENALTY
if has_bad_end(candidate.phrase_norm):
score -= BAD_END_SCORE_PENALTY
return score
def non_raw_source_count(candidate: PhraseCandidate) -> int:
"""Count the non-raw-ngram extraction sources that produced a candidate.
Args:
candidate (PhraseCandidate): Candidate whose enabled sources are counted.
Returns:
int: Number of enabled sources other than the raw n-gram slide.
"""
return sum(
(
candidate.source_yake,
candidate.source_capitalized,
candidate.source_metadata,
)
)
def has_bad_start(phrase_norm: str) -> bool:
"""Return whether a normalized phrase starts with a bad starting token.
Args:
phrase_norm (str): Normalized phrase text to inspect.
Returns:
bool: True when the first token is a known bad starting token.
"""
phrase_tokens = phrase_norm.split()
return bool(phrase_tokens and phrase_tokens[0] in get_bad_starts())
def has_bad_end(phrase_norm: str) -> bool:
"""Return whether a normalized phrase ends with a bad ending token.
Args:
phrase_norm (str): Normalized phrase text to inspect.
Returns:
bool: True when the last token is a known bad ending token.
"""
phrase_tokens = phrase_norm.split()
return bool(phrase_tokens and phrase_tokens[-1] in get_bad_ends())
def source_score(candidate: PhraseCandidate) -> float:
"""Return the score contribution from extraction sources.
Args:
candidate (PhraseCandidate): Candidate whose enabled sources are weighted.
Returns:
float: Summed weight of the candidate's enabled extraction sources.
"""
return sum(
weight
for enabled, weight in (
(candidate.source_yake, 2.0),
(candidate.source_capitalized, 2.0),
(candidate.source_metadata, 2.0),
(candidate.source_raw_ngram, 0.5),
)
if enabled
)
def frequency_score(candidate: PhraseCandidate, config: EbookSearchConfig) -> float:
"""Return the score contribution from frequency and chapter spread.
Args:
candidate (PhraseCandidate): Candidate whose counts are scored.
config (EbookSearchConfig): Runtime phrase-tuning settings holding score thresholds.
Returns:
float: Summed weight for each frequency and chapter-spread threshold the candidate meets.
"""
return sum(
weight
for count, threshold, weight in (
(candidate.raw_count, config.phrase_raw_count_score_threshold, 0.5),
(candidate.raw_count, config.phrase_raw_count_high_score_threshold, 0.5),
(candidate.chapter_count, config.phrase_chapter_count_score_threshold, 0.5),
(candidate.chapter_count, config.phrase_chapter_count_high_score_threshold, 0.5),
)
if count >= threshold
)
def token_count_score(candidate: PhraseCandidate, config: EbookSearchConfig) -> float:
"""Return the score contribution from phrase length.
Args:
candidate (PhraseCandidate): Candidate whose token count is scored.
config (EbookSearchConfig): Runtime phrase-tuning settings holding the max token bound.
Returns:
float: Length-based score contribution, which may be negative for over- or under-length phrases.
"""
if candidate.token_count == 1:
return -0.5
if candidate.token_count in {2, 3, 4}:
return 0.5
if candidate.token_count > config.phrase_max_tokens:
return -1.0
return 0.0
def get_sample_contexts(normalized_book_text: str, phrase_norm: str, max_contexts: int = 5) -> list[str]:
"""Return normalized context snippets containing a candidate phrase.
``normalized_book_text`` is expected to already be ``normalize_text``-ed by the caller
so the whole book is not re-normalized for every phrase.
Args:
normalized_book_text (str): Whole book text, already normalized, to search.
phrase_norm (str): Normalized phrase to find contexts around.
max_contexts (int): Maximum number of context snippets to return.
Returns:
list[str]: Up to ``max_contexts`` normalized snippets surrounding the phrase.
"""
contexts: list[str] = []
start = 0
while len(contexts) < max_contexts:
index = normalized_book_text.find(phrase_norm, start)
if index == -1:
break
left = max(0, index - 300)
right = min(len(normalized_book_text), index + len(phrase_norm) + 300)
contexts.append(normalized_book_text[left:right])
start = index + len(phrase_norm)
return contexts
def candidate_source_names(candidate: PhraseCandidate) -> list[str]:
"""Return enabled source names for an extracted candidate.
Args:
candidate (PhraseCandidate): Candidate whose enabled sources are listed.
Returns:
list[str]: Names of the extraction sources that produced the candidate.
"""
names: list[str] = []
if candidate.source_raw_ngram:
names.append("raw_ngram")
if candidate.source_yake:
names.append("yake")
if candidate.source_capitalized:
names.append("capitalized")
if candidate.source_metadata:
names.append("metadata")
return names
def extract_phrase_candidates_for_book(
book_text: str,
chapters: Sequence[str],
config: EbookSearchConfig,
*,
metadata: Mapping[str, object] | None = None,
) -> list[PhraseCandidate]:
"""Extract, score, and limit phrase candidates for one book.
Args:
book_text (str): Full book text used for most extraction sources.
chapters (Sequence[str]): Chapter-like text blocks used for frequency counts.
config (EbookSearchConfig): Runtime phrase-tuning settings.
metadata (Mapping[str, object] | None): Optional book metadata used as a candidate source.
Returns:
list[PhraseCandidate]: Scored candidates sorted best-first and capped per book.
"""
started_at = perf_counter()
logger.info(
f"ebook_phrase_candidate_extract_start chapters={len(chapters)} chars={len(book_text)} "
f"{config.phrase_min_tokens=} {config.phrase_max_tokens=} {config.protected_phrase_max_candidates_per_book=}"
)
raw_started_at = perf_counter()
raw = extract_raw_ngrams_by_chapter(chapters, config)
logger.info(
f"ebook_phrase_candidate_extract_raw_complete candidates={len(raw)} "
f"duration_ms={(perf_counter() - raw_started_at) * 1000:.1f}"
)
yake_started_at = perf_counter()
yake_candidates = extract_yake_candidates(book_text, config)
logger.info(
f"ebook_phrase_candidate_extract_yake_complete candidates={len(yake_candidates)} "
f"duration_ms={(perf_counter() - yake_started_at) * 1000:.1f}"
)
capitalized_started_at = perf_counter()
capitalized = extract_capitalized_phrases(book_text, config)
logger.info(
f"ebook_phrase_candidate_extract_capitalized_complete candidates={len(capitalized)} "
f"duration_ms={(perf_counter() - capitalized_started_at) * 1000:.1f}"
)
metadata_candidates = extract_metadata_candidates(metadata, config)
candidates = merge_candidate_sources(raw, yake_candidates, capitalized, metadata_candidates)
enriched_started_at = perf_counter()
# Raw n-gram sizes were already counted per chapter above, so only enrich the remaining
# (entity-length) sizes here instead of re-sliding every size over the whole book.
candidates = enrich_with_frequency_and_chapter_counts(
candidates,
chapters,
counted_sizes=range(config.phrase_min_tokens, config.phrase_max_tokens + 1),
)
pre_filter_count = len(candidates)
candidates, filtered_too_short, filtered_too_rare, filtered_too_common, filtered_junk = filter_storable_candidates(
candidates, config
)
for candidate in candidates.values():
candidate.candidate_score = score_candidate(candidate, config)
limited = sorted(candidates.values(), key=lambda item: item.candidate_score, reverse=True)[
: config.protected_phrase_max_candidates_per_book
]
logger.info(
f"ebook_phrase_candidate_extract_complete raw={len(raw)} yake={len(yake_candidates)} "
f"capitalized={len(capitalized)} metadata={len(metadata_candidates)} {pre_filter_count=} {filtered_too_short=} "
f"{filtered_too_rare=} {filtered_too_common=} {filtered_junk=} min_uses={minimum_candidate_raw_count(config)} "
f"storable={len(candidates)} limited={len(limited)} "
f"enrich_score_ms={(perf_counter() - enriched_started_at) * 1000:.1f} "
f"duration_ms={(perf_counter() - started_at) * 1000:.1f}"
)
return limited
@@ -1,295 +0,0 @@
"""Book-level orchestration for candidate n-gram generation and recalculation."""
from __future__ import annotations
import asyncio
import logging
from time import perf_counter
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from python.ebook_search.protected_phrases.extraction import extract_phrase_candidates_for_book
from python.ebook_search.protected_phrases.models import (
BookCandidateResult,
PhraseCandidateGenerationResult,
PhraseRecalculationResult,
)
from python.ebook_search.protected_phrases.pool import get_extraction_pool
from python.ebook_search.protected_phrases.store import (
bulk_upsert_unjudged_candidates,
delete_phrase_data_for_book,
load_book_chapter_texts,
metadata_for_source_id,
new_candidate_row,
prune_unstorable_unjudged_candidate_phrases,
)
from python.orm.common import get_async_postgres_engine
from python.orm.richie import EbookSource
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.protected_phrases.models import PhraseCandidate
logger = logging.getLogger(__name__)
class BookHasNoChaptersError(ValueError):
"""Raised when a book has no indexed chapter text to generate phrases from."""
async def generate_candidate_phrases_for_books(
engine: AsyncEngine,
config: EbookSearchConfig,
) -> PhraseCandidateGenerationResult:
"""Create or refresh candidate phrases for indexed books without calling the LLM judge.
Every book is submitted to the shared process pool up front and runs in parallel across the
pool's workers; the call blocks until all books have finished. Each worker opens its own
database engine from environment variables, loads the book's chapters, and commits the
book's candidates independently.
Args:
engine (AsyncEngine): Engine used to read the book list in this process.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
PhraseCandidateGenerationResult: Per-corpus counts of books seen, built, and candidates stored.
Results are collected in book order while the pool keeps working. A book failure (including
a book with no indexed chapters) is logged and counted as not built; the remaining books
are unaffected.
"""
async with AsyncSession(engine, expire_on_commit=False) as session:
source_query = select(EbookSource.id).order_by(EbookSource.id)
source_ids = (await session.scalars(source_query)).all()
books_seen = len(source_ids)
logger.info(
f"ebook_candidate_phrase_generation_start {books_seen=} {config.phrase_min_tokens=} "
f"{config.phrase_max_tokens=} {config.protected_phrase_max_candidates_per_book=}"
)
pool = get_extraction_pool(config.protected_phrase_extraction_workers)
wrapped_futures = [
(
source_id,
asyncio.wrap_future(pool.submit(generate_candidate_phrases_for_book_in_worker, source_id, None, config)),
)
for source_id in source_ids
]
outcomes: list[BookCandidateResult] = []
for source_id, wrapped_future in wrapped_futures:
await asyncio.wait([wrapped_future])
exception = wrapped_future.exception()
if exception is not None:
logger.error(f"ebook_candidate_phrase_generation_book_failed {source_id=}")
outcomes.append(BookCandidateResult())
continue
saved_count = wrapped_future.result()
logger.info(f"ebook_candidate_phrase_generation_book_committed {source_id=} {saved_count=}")
outcomes.append(BookCandidateResult(candidates=saved_count, built=True))
result = PhraseCandidateGenerationResult(
books_seen=books_seen,
books_built=sum(1 for outcome in outcomes if outcome.built),
candidate_phrases=sum(outcome.candidates for outcome in outcomes),
)
logger.info(
f"ebook_candidate_phrase_generation_complete {result.books_seen=} {result.books_built=} "
f"{result.candidate_phrases=}"
)
return result
async def recalculate_candidate_phrases_for_book(
session: AsyncSession,
source: EbookSource,
config: EbookSearchConfig,
) -> PhraseRecalculationResult:
"""Remove all book phrase data, regenerate candidates, and commit the completed book.
Args:
session (AsyncSession): Active database session; deletion and regeneration commit on it.
source (EbookSource): Indexed book to recalculate.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
PhraseRecalculationResult: Deleted-row counts and the number of candidates regenerated.
Raises:
BookHasNoChaptersError: If the book has no indexed chapters. The deletion is rolled
back, so the book's existing phrases stay intact.
The deletion and regeneration share the caller's session, so they commit together; a
regeneration failure rolls the deletion back.
"""
started_at = perf_counter()
logger.info(f"ebook_candidate_phrase_recalculation_start {source.id=} {source.title=}")
deleted = await delete_phrase_data_for_book(session, source.id)
candidate_count = await generate_candidate_phrases_for_book(
session,
source.id,
series_id=None,
config=config,
replace_all=True,
)
result = PhraseRecalculationResult(
book_id=source.id,
deleted_candidates=deleted.deleted_candidates,
deleted_protected_phrases=deleted.deleted_protected_phrases,
deleted_aliases=deleted.deleted_aliases,
deleted_mentions=deleted.deleted_mentions,
candidate_phrases=candidate_count,
)
logger.info(
f"ebook_candidate_phrase_recalculation_complete {source.id=} {result.deleted_candidates=} "
f"{result.deleted_protected_phrases=} {result.deleted_aliases=} {result.deleted_mentions=} "
f"{result.candidate_phrases=} duration_ms={(perf_counter() - started_at) * 1000:.1f}"
)
return result
def generate_candidate_phrases_for_book_in_worker(
book_id: int,
series_id: int | None,
config: EbookSearchConfig,
) -> int:
"""Run one book's candidate generation in a pooled worker process.
The worker has no engine or session to inherit (neither can cross process boundaries), so
it creates its own engine from environment variables, opens the book's session on it, and
disposes the engine once the book is stored.
Args:
book_id (int): Book the candidates belong to.
series_id (int | None): Series scope for the stored candidates.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
int: Number of candidate phrase rows stored.
"""
async def generate_with_worker_engine() -> int:
engine = get_async_postgres_engine(name="RICHIE", vector_engine=True, pool_size=1)
try:
async with AsyncSession(engine, expire_on_commit=False) as session:
return await generate_candidate_phrases_for_book(
session,
book_id,
series_id,
config,
)
finally:
await engine.dispose()
return asyncio.run(generate_with_worker_engine())
async def generate_candidate_phrases_for_book(
session: AsyncSession,
book_id: int,
series_id: int | None,
config: EbookSearchConfig,
*,
replace_all: bool = False,
) -> int:
"""Load a book's chapters and metadata, extract candidate phrases, and store them without LLM judging.
The session commits only when the whole book succeeds; any failure rolls the session back,
which also restores rows the caller deleted in the same transaction (e.g. a recalculation).
Args:
session (AsyncSession): Active database session; committed on success, rolled back on failure.
book_id (int): Book the candidates belong to.
series_id (int | None): Series scope for the stored candidates.
config (EbookSearchConfig): Runtime phrase-tuning settings.
replace_all (bool): When the caller has already cleared this book's candidates (e.g. a
recalculation), skip the per-candidate existence lookup and bulk-insert new rows.
Returns:
int: Number of candidate phrase rows stored.
Raises:
BookHasNoChaptersError: If the book has no indexed chapter text.
"""
started_at = perf_counter()
chapters = await load_book_chapter_texts(session, book_id)
if not chapters:
await session.rollback()
message = f"book {book_id} has no indexed chapters"
raise BookHasNoChaptersError(message)
metadata = await metadata_for_source_id(session, book_id)
try:
book_text = "\n\n".join(chapters)
candidates = extract_phrase_candidates_for_book(
book_text,
chapters,
config,
metadata=metadata,
)
saved_count = await store_candidate_phrases_for_book(
session,
book_id,
series_id,
candidates,
config,
replace_all=replace_all,
)
await session.commit()
except Exception:
await session.rollback()
raise
logger.info(
f"ebook_candidate_phrase_generation_book_duration {book_id=} {saved_count=} "
f"duration_ms={(perf_counter() - started_at) * 1000:.1f}"
)
return saved_count
async def store_candidate_phrases_for_book(
session: AsyncSession,
book_id: int,
series_id: int | None,
limited_candidates: list[PhraseCandidate],
config: EbookSearchConfig,
*,
replace_all: bool = False,
) -> int:
"""Persist already-extracted candidate phrase rows for one book without committing.
Args:
session (Session): Active database session.
book_id (int): Book the candidates belong to.
series_id (int | None): Series scope for the stored candidates.
limited_candidates (list[PhraseCandidate]): Scored candidates to persist.
config (EbookSearchConfig): Runtime phrase-tuning settings.
replace_all (bool): When the caller has already cleared this book's candidates, skip the
per-candidate existence lookup and bulk-insert new rows.
Returns:
int: Number of candidate phrase rows stored.
"""
save_started_at = perf_counter()
if replace_all:
rows = [new_candidate_row(book_id, series_id, candidate) for candidate in limited_candidates]
session.add_all(rows)
await session.flush()
saved_count = len(rows)
logger.info(
f"ebook_candidate_phrase_save_start {book_id=} candidates={len(limited_candidates)} mode=bulk_insert"
)
else:
pruned_count = await prune_unstorable_unjudged_candidate_phrases(session, book_id, config)
logger.info(
f"ebook_candidate_phrase_save_start {book_id=} candidates={len(limited_candidates)} {pruned_count=}"
)
saved_count = await bulk_upsert_unjudged_candidates(session, book_id, series_id, limited_candidates)
logger.info(
f"ebook_candidate_phrase_save_complete {book_id=} {saved_count=} "
f"save_ms={(perf_counter() - save_started_at) * 1000:.1f}"
)
return saved_count
@@ -1,471 +0,0 @@
"""Book-level orchestration for LLM judging and promotion of candidate phrases."""
from __future__ import annotations
import asyncio
import json
import logging
import re
from time import perf_counter
from typing import TYPE_CHECKING
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from python.ebook_search.llm_interface import request_chat_completion
from python.ebook_search.protected_phrases.extraction import (
candidate_source_names,
get_sample_contexts,
is_junk_phrase,
is_most_common_word_phrase,
score_candidate,
)
from python.ebook_search.protected_phrases.matching import index_chunk_phrase_mentions_for_book
from python.ebook_search.protected_phrases.models import BookJudgmentResult, LLMJudgment, PhraseJudgmentBackfillResult
from python.ebook_search.protected_phrases.store import (
count_protected_phrases,
count_unjudged_candidates,
load_book_text,
load_candidates_for_judgment,
phrase_candidate_from_row,
save_candidate_to_db,
upsert_protected_phrase,
)
from python.ebook_search.protected_phrases.text_normalization import normalize_text
from python.orm.richie import EbookSource
if TYPE_CHECKING:
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncEngine
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.protected_phrases.models import PhraseCandidate
from python.orm.richie import EbookProtectedPhrase
JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
logger = logging.getLogger(__name__)
async def judge_candidate_phrases_for_books(
engine: AsyncEngine,
config: EbookSearchConfig,
*,
source_ids: Sequence[int] | None = None,
) -> PhraseJudgmentBackfillResult:
"""Judge candidate phrases for books, fanning LLM calls out across books and phrases.
Up to ``phrase_judge_book_workers`` books are judged at once, and within each book candidates
are judged in concurrent chunks of ``phrase_judge_phrase_workers``. Each book uses its own
short-lived sessions for reads and writes; no database connection is held while LLM calls are
in flight. For a pseudo-single-threaded run (solo testing, debugging), set both worker
settings to 1.
Args:
engine (AsyncEngine): Engine used to open one session per book.
config (EbookSearchConfig): Runtime phrase-tuning settings and chat configuration.
source_ids (Sequence[int] | None): Books to judge; ``None`` judges every indexed book.
Returns:
PhraseJudgmentBackfillResult: Per-corpus counts of books judged, failures, candidates,
protected phrases, and mentions.
"""
if source_ids is None:
async with AsyncSession(engine) as session:
source_ids = list((await session.scalars(select(EbookSource.id).order_by(EbookSource.id))).all())
books_seen = len(source_ids)
book_workers = max(1, config.phrase_judge_book_workers)
phrase_workers = max(1, config.phrase_judge_phrase_workers)
logger.info(
f"ebook_candidate_phrase_judgment_start {books_seen=} {book_workers=} {phrase_workers=} "
f"{config.protected_phrase_confidence_threshold=:.2f}"
)
book_semaphore = asyncio.Semaphore(book_workers)
max_connections = book_workers * phrase_workers
limits = httpx.Limits(max_connections=max_connections, max_keepalive_connections=max_connections)
async with httpx.AsyncClient(limits=limits) as client:
outcomes = await asyncio.gather(
*(judge_one_book_async(engine, source_id, config, client, book_semaphore) for source_id in source_ids)
)
result = PhraseJudgmentBackfillResult(
books_seen=books_seen,
books_judged=sum(1 for outcome in outcomes if outcome.committed),
books_failed=sum(1 for outcome in outcomes if outcome.failed),
candidates_judged=sum(outcome.judged for outcome in outcomes),
protected_phrases=sum(outcome.protected for outcome in outcomes),
phrase_mentions=sum(outcome.mentions for outcome in outcomes),
)
logger.info(
f"ebook_candidate_phrase_judgment_complete {result.books_seen=} {result.books_judged=} {result.books_failed=} "
f"{result.candidates_judged=} {result.protected_phrases=} {result.phrase_mentions=}"
)
return result
async def judge_one_book_async(
engine: AsyncEngine,
source_id: int,
config: EbookSearchConfig,
client: httpx.AsyncClient,
book_semaphore: asyncio.Semaphore,
) -> BookJudgmentResult:
"""Judge one book concurrently and persist the outcome, honoring the book-level limit.
Args:
engine (AsyncEngine): Engine used to open the book's read and write sessions.
source_id (int): Book to judge candidates for.
config (EbookSearchConfig): Runtime phrase-tuning settings.
client (httpx.AsyncClient): Shared async client for LLM calls.
book_semaphore (asyncio.Semaphore): Caps how many books judge at once.
Returns:
BookJudgmentResult: The book's judgment outcome.
"""
async with book_semaphore:
try:
prepared = await prepare_book_judgment(engine, source_id, config)
if prepared is None:
return BookJudgmentResult()
work_items, target_remaining = prepared
judged = await judge_book_candidates_async(client, config, source_id, work_items, target_remaining)
if not judged:
return BookJudgmentResult()
return await persist_book_judgments(engine, source_id, config, judged)
except Exception:
logger.exception(f"ebook_candidate_phrase_judgment_book_failed {source_id=}")
return BookJudgmentResult(failed=True)
async def prepare_book_judgment(
engine: AsyncEngine,
source_id: int,
config: EbookSearchConfig,
) -> tuple[list[tuple[int, PhraseCandidate]], int | None] | None:
"""Load one book's candidates to judge, with sample contexts, on a short-lived read session.
Args:
engine (AsyncEngine): Engine used to open the read session.
source_id (int): Book to load candidates for.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
tuple[list[tuple[int, PhraseCandidate]], int | None] | None: Candidate rows paired with
in-memory candidates and the remaining protected-phrase target, or ``None`` when the book
has nothing to judge.
"""
judgment_limit = config.protected_phrase_llm_candidates_per_book
if judgment_limit <= 0:
return None
async with AsyncSession(engine) as session:
if not await count_unjudged_candidates(session, source_id, config):
logger.info(f"ebook_candidate_phrase_judgment_book_skip_no_unjudged {source_id=}")
return None
existing_protected = await count_protected_phrases(session, source_id)
target_remaining: int | None = None
if config.phrase_target_protected_per_book > 0:
target_remaining = max(config.phrase_target_protected_per_book - existing_protected, 0)
if target_remaining == 0:
logger.info(
f"ebook_candidate_phrase_judgment_skipped_target_met {source_id=} {existing_protected=} "
f"{config.phrase_target_protected_per_book=}"
)
return None
book_text = await load_book_text(session, source_id)
if not book_text:
logger.warning(f"ebook_candidate_phrase_judgment_book_empty {source_id=}")
return None
normalized_book_text = normalize_text(book_text)
# Stored rows may predate the current junk filters and score weights, so re-filter and
# rescore every unjudged row here instead of trusting the persisted candidate_score.
rows = await load_candidates_for_judgment(session, source_id, config)
scored_items: list[tuple[int, PhraseCandidate]] = []
skipped_junk = 0
for row in rows:
candidate = phrase_candidate_from_row(row)
if is_junk_phrase(candidate.phrase_norm.split()):
skipped_junk += 1
continue
candidate.candidate_score = score_candidate(candidate, config)
scored_items.append((row.id, candidate))
scored_items.sort(key=lambda item: item[1].candidate_score, reverse=True)
work_items = scored_items[:judgment_limit]
for _, candidate in work_items:
candidate.sample_contexts = candidate.sample_contexts or get_sample_contexts(
normalized_book_text, candidate.phrase_norm
)
logger.info(
f"ebook_candidate_phrase_judgment_candidates_loaded {source_id=} candidates={len(work_items)} {skipped_junk=} "
f"unjudged_rows={len(rows)} {existing_protected=} {target_remaining=} {judgment_limit=}"
)
return work_items, target_remaining
async def judge_book_candidates_async(
client: httpx.AsyncClient,
config: EbookSearchConfig,
source_id: int,
work_items: list[tuple[int, PhraseCandidate]],
target_remaining: int | None,
) -> list[tuple[int, PhraseCandidate, LLMJudgment, bool]]:
"""Judge a book's candidates in concurrent chunks, stopping once the target is reached.
Promotion decisions are made in memory so judging can stop early without any database writes.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
config (EbookSearchConfig): Runtime phrase-tuning settings.
source_id (int): Book being judged, for logging.
work_items (list[tuple[int, PhraseCandidate]]): Candidate row ids paired with candidates,
in best-first score order.
target_remaining (int | None): Remaining protected-phrase target, or ``None`` for no cap.
Returns:
list[tuple[int, PhraseCandidate, LLMJudgment, bool]]: Judged rows with their judgment and
whether each should be promoted.
"""
chunk_size = max(1, config.phrase_judge_phrase_workers)
judged: list[tuple[int, PhraseCandidate, LLMJudgment, bool]] = []
promoted = 0
for start in range(0, len(work_items), chunk_size):
chunk = work_items[start : start + chunk_size]
judgments = await asyncio.gather(*(judge_candidate_async(client, config, candidate) for _, candidate in chunk))
for (candidate_id, candidate), judgment in zip(chunk, judgments, strict=True):
promote = (target_remaining is None or promoted < target_remaining) and should_protect_judged_candidate(
candidate, judgment, source_id, config, candidate_id=candidate_id
)
if promote:
promoted += 1
judged.append((candidate_id, candidate, judgment, promote))
if target_remaining is not None and promoted >= target_remaining:
break
return judged
async def judge_candidate_async(
client: httpx.AsyncClient,
config: EbookSearchConfig,
candidate: PhraseCandidate,
) -> LLMJudgment:
"""Judge one candidate with the LLM over the shared async client.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
config (EbookSearchConfig): Runtime phrase-tuning settings.
candidate (PhraseCandidate): Candidate to judge.
Returns:
LLMJudgment: The parsed judgment.
"""
content = await request_chat_completion(client, config, build_judge_messages(candidate))
return parse_llm_judgment(content, config)
async def persist_book_judgments(
engine: AsyncEngine,
source_id: int,
config: EbookSearchConfig,
judged: list[tuple[int, PhraseCandidate, LLMJudgment, bool]],
) -> BookJudgmentResult:
"""Persist one book's judgments and promotions in a single committed transaction.
Args:
engine (AsyncEngine): Engine used to open the write session.
source_id (int): Book being persisted.
config (EbookSearchConfig): Runtime phrase-tuning settings.
judged (list[tuple[int, PhraseCandidate, LLMJudgment, bool]]): Judged candidates with their
judgment and promotion flag.
Returns:
BookJudgmentResult: The book's committed counts, or a failed result on error.
"""
book_started_at = perf_counter()
async with AsyncSession(engine, expire_on_commit=False) as session:
try:
protected: list[EbookProtectedPhrase] = []
for candidate_id, candidate, judgment, promote in judged:
candidate_row = await save_candidate_to_db(session, source_id, None, candidate, judgment=judgment)
if promote:
protected.append(
await upsert_protected_phrase(session, source_id, None, candidate, judgment, candidate_row)
)
logger.info(
f"ebook_candidate_phrase_judgment_candidate_complete {source_id=} {candidate_id=} "
f"{candidate.phrase_norm=} {judgment.keep=} {judgment.confidence=:.3f} {judgment.category=} "
f"{promote=}"
)
await session.flush()
mentions = await index_chunk_phrase_mentions_for_book(session, source_id, config) if protected else 0
await session.commit()
except Exception:
await session.rollback()
logger.exception(f"ebook_candidate_phrase_judgment_book_persist_failed {source_id=}")
return BookJudgmentResult(failed=True)
logger.info(
f"ebook_candidate_phrase_judgment_book_committed {source_id=} judged={len(judged)} protected={len(protected)} "
f"{mentions=} duration_ms={(perf_counter() - book_started_at) * 1000:.1f}"
)
return BookJudgmentResult(judged=len(judged), protected=len(protected), mentions=mentions, committed=True)
def should_protect_judged_candidate(
candidate: PhraseCandidate,
judgment: LLMJudgment,
book_id: int,
config: EbookSearchConfig,
*,
candidate_id: int,
) -> bool:
"""Report whether a judged candidate qualifies to become a protected phrase.
Args:
candidate (PhraseCandidate): In-memory candidate that was judged.
judgment (LLMJudgment): Judge decision for the candidate.
book_id (int): Book the candidate belongs to, for logging.
config (EbookSearchConfig): Runtime phrase-tuning settings.
candidate_id (int): Stored candidate row id the judgment came from, for logging.
Returns:
bool: True when the judged candidate should be promoted to a protected phrase.
"""
if not judgment.keep or judgment.confidence < config.protected_phrase_confidence_threshold:
return False
accepted_norm = normalize_text(judgment.canonical or candidate.phrase_text)
accepted_tokens = accepted_norm.split()
accepted_token_count = len(accepted_tokens)
if accepted_token_count < config.phrase_min_tokens:
logger.info(
f"ebook_candidate_phrase_judgment_candidate_skip_short_canonical {book_id=} {candidate_id=} "
f"{candidate.phrase_norm=} {accepted_norm=} {accepted_token_count=} {config.phrase_min_tokens=}"
)
return False
if is_most_common_word_phrase(accepted_tokens):
logger.info(
f"ebook_candidate_phrase_judgment_candidate_skip_common_canonical {book_id=} {candidate_id=} "
f"{candidate.phrase_norm=} {accepted_norm=}"
)
return False
return True
def build_judge_messages(candidate: PhraseCandidate) -> list[dict[str, str]]:
"""Build the chat messages used to judge one candidate phrase.
Args:
candidate (PhraseCandidate): Candidate to describe for the judge.
Returns:
list[dict[str, str]]: OpenAI-style system and user messages.
"""
payload = {
"phrase": candidate.phrase_norm,
"token_count": candidate.token_count,
"sources": candidate_source_names(candidate),
"raw_count": candidate.raw_count,
"chapter_count": candidate.chapter_count,
"contexts": candidate.sample_contexts,
}
return [
{
"role": "system",
"content": (
"Judge whether a candidate phrase from a book should be protected for RAG retrieval. "
"Do not extract new phrases. Reject common grammar fragments, ordinary nonspecific phrases, "
"unstable fragments, and phrases kept only because they are frequent. Keep people, places, "
"organizations, factions, events, technologies, fictional conditions, magic systems, formal titles, "
"named concepts, and recurring world-specific terms. Return only a JSON object with keys: keep, "
"canonical, category, aliases, confidence, importance, allow_nested, suppress_children, reason."
),
},
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
]
def parse_llm_judgment(content: str, config: EbookSearchConfig) -> LLMJudgment:
"""Parse and validate an LLM phrase-judge response.
Args:
content (str): Raw model response text.
config (EbookSearchConfig): Runtime phrase-tuning settings supplying nesting defaults.
Returns:
LLMJudgment: The parsed and validated judgment.
Raises:
TypeError: If the decoded JSON body is not an object.
"""
body = json.loads(extract_json_object(content))
if not isinstance(body, dict):
msg = "LLM phrase judge response is not a JSON object"
raise TypeError(msg)
aliases = body.get("aliases", ())
if not isinstance(aliases, list | tuple):
aliases = ()
return LLMJudgment(
keep=bool(body.get("keep", False)),
canonical=optional_text(body.get("canonical")),
category=optional_text(body.get("category")),
aliases=tuple(str(alias) for alias in aliases if isinstance(alias, str) and alias.strip()),
confidence=clamped_float(body.get("confidence"), default=0.0),
importance=clamped_float(body.get("importance"), default=0.5),
allow_nested=bool(body.get("allow_nested", config.phrase_default_allow_nested)),
suppress_children=bool(body.get("suppress_children", config.phrase_default_suppress_children)),
reason=optional_text(body.get("reason")),
)
def extract_json_object(content: str) -> str:
"""Extract a JSON object from plain or fenced model output.
Args:
content (str): Raw model response text.
Returns:
str: The substring spanning the first JSON object.
Raises:
ValueError: If no JSON object is found in the response.
"""
stripped = content.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return stripped
match = JSON_OBJECT_RE.search(stripped)
if match is None:
msg = "LLM phrase judge response did not contain a JSON object"
raise ValueError(msg)
return match.group(0)
def optional_text(value: object) -> str | None:
"""Return stripped text for a nullable JSON value.
Args:
value (object): Decoded JSON value that may or may not be a string.
Returns:
str | None: The stripped string, or ``None`` when it is not a non-empty string.
"""
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def clamped_float(value: object, *, default: float) -> float:
"""Coerce a JSON number into the 0.0 to 1.0 range.
Args:
value (object): Decoded JSON value that may or may not be a number.
default (float): Fallback returned when ``value`` is not numeric.
Returns:
float: The value clamped to ``[0.0, 1.0]``, or ``default`` when non-numeric.
"""
if not isinstance(value, int | float):
return default
return min(max(float(value), 0.0), 1.0)
@@ -1,357 +0,0 @@
"""Runtime protected-phrase matching and chunk mention indexing."""
from __future__ import annotations
import logging
from collections import defaultdict
from typing import TYPE_CHECKING
from sqlalchemy import and_, delete, or_, select, union
from python.ebook_search.protected_phrases.config import get_ignored_phrases
from python.ebook_search.protected_phrases.models import (
PhraseLookup,
PhraseMatch,
)
from python.ebook_search.protected_phrases.text_normalization import tokenize_with_offsets
from python.orm.richie import (
EbookChunk,
EbookChunkPhraseMention,
EbookPhraseAlias,
EbookProtectedPhrase,
)
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from python.ebook_search.config import EbookSearchConfig
logger = logging.getLogger(__name__)
async def detect_protected_phrases_for_query(
session: AsyncSession,
query_text: str,
config: EbookSearchConfig,
) -> list[PhraseMatch]:
"""Find query phrases with indexed exact matches on canonical and alias norms.
Args:
session (AsyncSession): Active database session.
query_text (str): User query text to detect phrases in.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
list[PhraseMatch]: Metadata-backed, overlap-resolved phrase matches for the query.
"""
tokens_ = tokenize_with_offsets(query_text)
windows_by_norm: defaultdict[str, list[tuple[int, int]]] = defaultdict(list)
token_texts = [token.text for token in tokens_]
max_tokens = max(config.phrase_max_tokens, config.phrase_max_entity_tokens)
for phrase_norm, start, end in generate_query_ngrams(
token_texts,
min_n=config.phrase_min_tokens,
max_n=max_tokens,
):
windows_by_norm[phrase_norm].append((start, end))
if not windows_by_norm:
return []
query_norms = tuple(windows_by_norm)
matched_norms = union(
select(
EbookProtectedPhrase.id.label("phrase_id"),
EbookProtectedPhrase.phrase_norm.label("matched_norm"),
).where(EbookProtectedPhrase.phrase_norm.in_(query_norms)),
select(
EbookPhraseAlias.phrase_id.label("phrase_id"),
EbookPhraseAlias.alias_norm.label("matched_norm"),
).where(EbookPhraseAlias.alias_norm.in_(query_norms)),
).subquery()
statement = select(EbookProtectedPhrase, matched_norms.c.matched_norm).join(
matched_norms,
matched_norms.c.phrase_id == EbookProtectedPhrase.id,
)
matches: list[PhraseMatch] = []
for phrase, matched_norm in await session.execute(statement):
for start, end in windows_by_norm[matched_norm]:
matches.append(
PhraseMatch(
phrase_id=phrase.id,
matched_norm=matched_norm,
phrase_text=phrase.phrase_text,
phrase_norm=phrase.phrase_norm,
canonical_id=phrase.canonical_id,
phrase_type=phrase.phrase_type,
token_count=end - start,
confidence=phrase.confidence,
importance=phrase.importance,
allow_nested=phrase.allow_nested,
suppress_children=phrase.suppress_children,
start_token=start,
end_token=end,
start_char=tokens_[start].start_char,
end_char=tokens_[end - 1].end_char,
book_id=phrase.book_id,
series_id=phrase.series_id,
)
)
return resolve_overlaps(matches)
async def index_chunk_phrase_mentions_for_book(
session: AsyncSession,
book_id: int,
config: EbookSearchConfig,
) -> int:
"""Rebuild chunk phrase mentions for all chunks in one book.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose chunk mentions are rebuilt.
config (EbookSearchConfig): Runtime phrase-tuning settings.
Returns:
int: Total number of chunk phrase mentions indexed for the book.
"""
lookup = await load_phrase_lookup(session, config, book_id=book_id)
await session.execute(delete(EbookChunkPhraseMention).where(EbookChunkPhraseMention.book_id == book_id))
chunks = await session.scalars(select(EbookChunk).where(EbookChunk.source_id == book_id).order_by(EbookChunk.id))
count = 0
for chunk in chunks:
count += index_chunk_phrase_mentions(session, chunk, lookup=lookup)
await session.flush()
logger.info(f"ebook_chunk_phrase_mentions_indexed {book_id=} {count=}")
return count
async def load_phrase_lookup(
session: AsyncSession,
config: EbookSearchConfig,
*,
book_id: int | None = None,
series_id: int | None = None,
) -> PhraseLookup:
"""Load protected phrases and aliases into RAM lookup maps.
Args:
session (AsyncSession): Active database session.
config (EbookSearchConfig): Runtime phrase-tuning settings.
book_id (int | None): Optional book scope to restrict loaded phrases.
series_id (int | None): Optional series scope to restrict loaded phrases.
Returns:
PhraseLookup: Normalized phrase and alias maps with the token-window bounds to test.
"""
phrase_ids_by_norm: defaultdict[str, set[int]] = defaultdict(set)
phrases_by_id: dict[int, EbookProtectedPhrase] = {}
max_tokens = config.phrase_max_tokens
statement = select(
EbookProtectedPhrase,
EbookPhraseAlias.alias_norm,
).outerjoin(EbookPhraseAlias, EbookPhraseAlias.phrase_id == EbookProtectedPhrase.id)
scope_filter = protected_phrase_scope_filter(book_id=book_id, series_id=series_id)
if scope_filter is not None:
statement = statement.where(scope_filter)
for phrase, alias_norm in await session.execute(statement):
phrases_by_id[phrase.id] = phrase
phrase_ids_by_norm[phrase.phrase_norm].add(phrase.id)
max_tokens = max(max_tokens, phrase.token_count)
if alias_norm is not None:
phrase_ids_by_norm[alias_norm].add(phrase.id)
max_tokens = max(max_tokens, len(alias_norm.split()))
return PhraseLookup(
phrase_ids_by_norm={key: tuple(sorted(values)) for key, values in phrase_ids_by_norm.items()},
phrases_by_id=phrases_by_id,
min_tokens=config.phrase_min_tokens,
max_tokens=max_tokens,
)
def protected_phrase_scope_filter(*, book_id: int | None, series_id: int | None) -> object | None:
"""Build a SQLAlchemy filter for optional phrase book and series scope.
Args:
book_id (int | None): Optional book scope to include alongside global phrases.
series_id (int | None): Optional series scope to include alongside global phrases.
Returns:
object | None: A combined SQLAlchemy filter clause, or ``None`` when no scope is given.
"""
conditions = []
if book_id is not None:
conditions.append(or_(EbookProtectedPhrase.book_id.is_(None), EbookProtectedPhrase.book_id == book_id))
if series_id is not None:
conditions.append(or_(EbookProtectedPhrase.series_id.is_(None), EbookProtectedPhrase.series_id == series_id))
if not conditions:
return None
return and_(*conditions)
def is_inside(child: PhraseMatch, parent: PhraseMatch) -> bool:
"""Return whether one token span is strictly inside another.
Args:
child (PhraseMatch): Candidate nested match.
parent (PhraseMatch): Candidate enclosing match.
Returns:
bool: True when ``child`` lies within ``parent`` and is not the same span.
"""
return (
child.start_token >= parent.start_token
and child.end_token <= parent.end_token
and (child.start_token, child.end_token, child.phrase_id)
!= (parent.start_token, parent.end_token, parent.phrase_id)
)
def index_chunk_phrase_mentions(session: AsyncSession, chunk: EbookChunk, *, lookup: PhraseLookup) -> int:
"""Store protected phrase mentions for one chunk.
Args:
session (AsyncSession): Active database session.
chunk (EbookChunk): Chunk whose text is scanned for phrase mentions.
lookup (PhraseLookup): In-memory phrase and alias lookup maps.
Returns:
int: Number of phrase mentions stored for the chunk.
"""
tokens_ = tokenize_with_offsets(chunk.text)
token_texts = [token.text for token in tokens_]
raw_matches: list[PhraseMatch] = []
phrase_windows = generate_query_ngrams(token_texts, min_n=lookup.min_tokens, max_n=lookup.max_tokens)
for matched_norm, start, end in phrase_windows:
for phrase_id in lookup.phrase_ids_by_norm.get(matched_norm, ()):
phrase = lookup.phrases_by_id[phrase_id]
raw_matches.append(
PhraseMatch(
phrase_id=phrase_id,
matched_norm=matched_norm,
phrase_text=phrase.phrase_text,
phrase_norm=phrase.phrase_norm,
canonical_id=phrase.canonical_id,
phrase_type=phrase.phrase_type,
confidence=phrase.confidence,
importance=phrase.importance,
allow_nested=phrase.allow_nested,
suppress_children=phrase.suppress_children,
start_token=start,
end_token=end,
token_count=end - start,
start_char=tokens_[start].start_char,
end_char=tokens_[end - 1].end_char,
book_id=phrase.book_id,
series_id=phrase.series_id,
)
)
matches = resolve_overlaps(raw_matches)
for match in matches:
session.add(
EbookChunkPhraseMention(
chunk_id=chunk.id,
phrase_id=match.phrase_id,
book_id=match.book_id if match.book_id is not None else chunk.source_id,
series_id=match.series_id,
start_char=match.start_char if match.start_char is not None else 0,
end_char=match.end_char,
)
)
return len(matches)
def resolve_overlaps(matches: Sequence[PhraseMatch]) -> list[PhraseMatch]:
"""Resolve overlapping phrase matches without relying only on longest match.
Args:
matches (Sequence[PhraseMatch]): Metadata-backed matches that may overlap.
Returns:
list[PhraseMatch]: The kept, non-suppressed matches.
"""
sorted_matches = sorted(
matches,
key=lambda match: (match.start_token, -match.token_count, -match.importance, -match.confidence),
)
kept: list[PhraseMatch] = []
for candidate in sorted_matches:
if any(should_suppress(candidate, existing) for existing in kept):
continue
kept.append(candidate)
return kept
def should_suppress(candidate: PhraseMatch, kept: PhraseMatch) -> bool:
"""Return whether an already-kept match should suppress a candidate.
Args:
candidate (PhraseMatch): Match being considered for keeping.
kept (PhraseMatch): Match already kept that may suppress the candidate.
Returns:
bool: True when the candidate should be dropped in favor of the kept match.
"""
if not overlaps(candidate, kept):
return False
if candidate.canonical_id == kept.canonical_id:
return rank_match(kept) >= rank_match(candidate)
if is_inside(candidate, kept) and kept.suppress_children and not candidate.allow_nested:
return True
return not candidate.allow_nested and rank_match(kept) > rank_match(candidate)
def overlaps(first: PhraseMatch, second: PhraseMatch) -> bool:
"""Return whether two token spans overlap.
Args:
first (PhraseMatch): First match to compare.
second (PhraseMatch): Second match to compare.
Returns:
bool: True when the two token spans share at least one token position.
"""
return not (first.end_token <= second.start_token or first.start_token >= second.end_token)
def rank_match(match: PhraseMatch) -> tuple[float, float, int]:
"""Rank phrase matches by importance, confidence, then token count.
Args:
match (PhraseMatch): Match to build a sort key for.
Returns:
tuple[float, float, int]: A comparable key of importance, confidence, and token count.
"""
return (match.importance, match.confidence, match.token_count)
def generate_query_ngrams(
tokens_: Sequence[str],
min_n: int,
max_n: int,
) -> Iterator[tuple[str, int, int]]:
"""Generate normalized query windows from longest to shortest.
Args:
tokens_ (Sequence[str]): Normalized query tokens.
min_n (int): Smallest window size to yield.
max_n (int): Largest window size to yield, capped at the token count.
Yields:
tuple[str, int, int]: Normalized window text with its start and end token indices.
"""
capped_max_n = min(max_n, len(tokens_))
for ngram_size in range(capped_max_n, min_n - 1, -1):
for start in range(len(tokens_) - ngram_size + 1):
end = start + ngram_size
phrase_norm = " ".join(tokens_[start:end])
if phrase_norm in get_ignored_phrases():
continue
yield phrase_norm, start, end
@@ -1,243 +0,0 @@
"""Dataclasses shared by protected phrase extraction, judging, matching, and backfills."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Mapping
from python.orm.richie import EbookProtectedPhrase
@dataclass(slots=True)
class PhraseCandidate:
"""A phrase candidate with merged extraction-source metadata.
Attributes:
phrase_text (str): Display text for the phrase.
phrase_norm (str): Normalized phrase used as the merge key.
token_count (int): Number of normalized tokens in the phrase.
source_raw_ngram (bool): Whether the raw n-gram extractor produced the phrase.
source_yake (bool): Whether YAKE keyword extraction produced the phrase.
source_capitalized (bool): Whether the capitalized-run extractor produced the phrase.
source_metadata (bool): Whether book metadata produced the phrase.
raw_count (int): Occurrences counted across the book text.
chapter_count (int): Number of chapters containing the phrase.
yake_score (float | None): Raw YAKE score when available; lower is better.
candidate_score (float): Combined pre-judging score.
sample_contexts (list[str]): Normalized context snippets around occurrences.
"""
phrase_text: str
phrase_norm: str
token_count: int
source_raw_ngram: bool = False
source_yake: bool = False
source_capitalized: bool = False
source_metadata: bool = False
raw_count: int = 0
chapter_count: int = 0
yake_score: float | None = None
candidate_score: float = 0.0
sample_contexts: list[str] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class LLMJudgment:
"""A structured phrase judgment returned by the LLM judge.
Attributes:
keep (bool): Whether the judge accepted the phrase for protection.
canonical (str | None): Canonical phrase text chosen by the judge.
category (str | None): Phrase category such as person, place, or event.
aliases (tuple[str, ...]): Alternate surface forms for the phrase.
confidence (float): Judge confidence between 0.0 and 1.0.
importance (float): Judge importance between 0.0 and 1.0.
allow_nested (bool): Whether the phrase may match inside a larger kept match.
suppress_children (bool): Whether the phrase suppresses matches nested inside it.
reason (str | None): Free-text explanation from the judge.
"""
keep: bool
canonical: str | None
category: str | None
aliases: tuple[str, ...]
confidence: float
importance: float = 0.5
allow_nested: bool = False
suppress_children: bool = True
reason: str | None = None
@dataclass(frozen=True, slots=True)
class PhraseLookup:
"""In-memory phrase metadata used for constant-time text-window checks.
Attributes:
phrase_ids_by_norm (Mapping[str, tuple[int, ...]]): Canonical and alias norms to phrase ids.
phrases_by_id (Mapping[int, EbookProtectedPhrase]): Protected phrase metadata by id.
min_tokens (int): Smallest token-window size to test.
max_tokens (int): Largest token-window size to test.
"""
phrase_ids_by_norm: Mapping[str, tuple[int, ...]]
phrases_by_id: Mapping[int, EbookProtectedPhrase]
min_tokens: int
max_tokens: int
@dataclass(frozen=True, slots=True)
class PhraseMatch:
"""A detected phrase match with protected-phrase metadata attached.
Attributes:
phrase_id (int): Protected phrase id.
matched_norm (str): Normalized window text that matched.
phrase_text (str): Display text of the protected phrase.
phrase_norm (str): Normalized text of the protected phrase.
canonical_id (str): Deterministic ``category:slug`` identifier.
phrase_type (str | None): Phrase category.
token_count (int): Number of tokens in the match.
confidence (float): Stored judge confidence.
importance (float): Stored judge importance.
allow_nested (bool): Whether the phrase may match inside a larger kept match.
suppress_children (bool): Whether the phrase suppresses matches nested inside it.
start_token (int): Index of the first matched token.
end_token (int): Index one past the last matched token.
start_char (int | None): Start character offset in the source text.
end_char (int | None): End character offset in the source text.
book_id (int | None): Book scope of the phrase.
series_id (int | None): Series scope of the phrase.
"""
phrase_id: int
matched_norm: str
phrase_text: str
phrase_norm: str
canonical_id: str
phrase_type: str | None
token_count: int
confidence: float
importance: float
allow_nested: bool
suppress_children: bool
start_token: int
end_token: int
start_char: int | None = None
end_char: int | None = None
book_id: int | None = None
series_id: int | None = None
@dataclass(frozen=True, slots=True)
class PhraseCandidateGenerationResult:
"""Summary of candidate phrase extraction for indexed books.
Attributes:
books_seen (int): Indexed books examined.
books_built (int): Books that had candidates generated and committed.
candidate_phrases (int): Candidate phrases stored across all books.
"""
books_seen: int
books_built: int
candidate_phrases: int
@dataclass(frozen=True, slots=True)
class CorpusPhraseStats:
"""Corpus-wide candidate and protected phrase counts for the admin page.
Attributes:
total_books (int): Indexed books in the corpus.
books_with_candidates (int): Books that have candidate phrases generated.
books_fully_judged (int): Books with candidates where every candidate has been judged.
candidate_phrases (int): Candidate phrases stored across all books.
judged_candidates (int): Candidate phrases that have been LLM judged.
unjudged_candidates (int): Candidate phrases still waiting for judgment.
protected_phrases (int): Protected phrases promoted across all books.
"""
total_books: int
books_with_candidates: int
books_fully_judged: int
candidate_phrases: int
judged_candidates: int
unjudged_candidates: int
protected_phrases: int
@dataclass(frozen=True, slots=True)
class PhraseJudgmentBackfillResult:
"""Summary of LLM judging for stored candidate phrases.
Attributes:
books_seen (int): Indexed books examined.
books_judged (int): Books with judgments committed.
books_failed (int): Books rolled back after an error.
candidates_judged (int): Candidate phrases sent to the LLM judge.
protected_phrases (int): Protected phrases promoted from candidates.
phrase_mentions (int): Chunk phrase mentions indexed across all books.
"""
books_seen: int
books_judged: int
books_failed: int
candidates_judged: int
protected_phrases: int
phrase_mentions: int
@dataclass(frozen=True, slots=True)
class BookJudgmentResult:
"""Outcome of judging one book's candidate phrases.
Attributes:
judged (int): Candidate phrases sent to the LLM judge.
protected (int): Protected phrases promoted from candidates.
mentions (int): Chunk phrase mentions indexed for the book.
committed (bool): Whether the book's judgments were committed.
failed (bool): Whether the book was rolled back after an error.
"""
judged: int = 0
protected: int = 0
mentions: int = 0
committed: bool = False
failed: bool = False
@dataclass(frozen=True, slots=True)
class BookCandidateResult:
"""Outcome of generating one book's candidate phrases.
Attributes:
candidates (int): Candidate phrases stored for the book.
built (bool): Whether candidate generation was committed.
"""
candidates: int = 0
built: bool = False
@dataclass(frozen=True, slots=True)
class PhraseRecalculationResult:
"""Summary of phrase cleanup and candidate regeneration for one book.
Attributes:
book_id (int): Book the recalculation ran against.
deleted_candidates (int): Candidate phrase rows deleted.
deleted_protected_phrases (int): Protected phrase rows deleted.
deleted_aliases (int): Phrase alias rows deleted.
deleted_mentions (int): Chunk phrase mention rows deleted.
candidate_phrases (int): Candidate phrases regenerated after cleanup.
"""
book_id: int
deleted_candidates: int
deleted_protected_phrases: int
deleted_aliases: int
deleted_mentions: int
candidate_phrases: int
@@ -1,58 +0,0 @@
"""Process pool for offloading CPU-bound phrase extraction off the request thread.
Phrase extraction is pure-Python CPU work (n-gram sliding, YAKE), so running it inline in a
sync request handler serializes concurrent recalculations behind the GIL. Submitting it to a
``ProcessPoolExecutor`` lets concurrent extractions run in parallel across cores instead. A
``spawn`` context is used so workers do not inherit the parent's database engine, connections,
or server threads.
"""
from __future__ import annotations
import logging
import multiprocessing
import os
from concurrent.futures import ProcessPoolExecutor
from threading import Lock
logger = logging.getLogger(__name__)
class _ExtractionPool:
"""Lazily created process-wide extraction pool and the lock guarding it."""
def __init__(self) -> None:
self.lock = Lock()
self.pool: ProcessPoolExecutor | None = None
_extraction_pool = _ExtractionPool()
def get_extraction_pool(max_workers: int) -> ProcessPoolExecutor:
"""Return the shared extraction process pool, creating it on first use.
Args:
max_workers (int): Desired worker count; values below 1 fall back to the CPU count.
Returns:
ProcessPoolExecutor: The shared pool for phrase extraction.
"""
with _extraction_pool.lock:
if _extraction_pool.pool is None:
workers = max_workers if max_workers > 0 else (os.cpu_count() or 1)
_extraction_pool.pool = ProcessPoolExecutor(
max_workers=workers,
mp_context=multiprocessing.get_context("spawn"),
)
logger.info(f"ebook_phrase_extraction_pool_started {workers=}")
return _extraction_pool.pool
def shutdown_extraction_pool() -> None:
"""Shut down the shared extraction pool if it was started."""
with _extraction_pool.lock:
if _extraction_pool.pool is not None:
_extraction_pool.pool.shutdown(wait=False, cancel_futures=True)
_extraction_pool.pool = None
logger.info("ebook_phrase_extraction_pool_shutdown")
@@ -1,684 +0,0 @@
"""Database persistence for candidate and protected phrase rows."""
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING
from sqlalchemy import delete, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from python.ebook_search.protected_phrases.extraction import minimum_candidate_raw_count
from python.ebook_search.protected_phrases.models import (
CorpusPhraseStats,
PhraseCandidate,
PhraseRecalculationResult,
)
from python.ebook_search.protected_phrases.text_normalization import normalize_text
from python.orm.richie import (
EbookCandidatePhrase,
EbookChunk,
EbookChunkPhraseMention,
EbookPhraseAlias,
EbookProtectedPhrase,
EbookSource,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from sqlalchemy.dialects.postgresql.dml import Insert as PostgresInsert
from sqlalchemy.dialects.sqlite.dml import Insert as SqliteInsert
from sqlalchemy.ext.asyncio import AsyncSession
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.protected_phrases.models import LLMJudgment
from python.orm.richie.base import TableBase
logger = logging.getLogger(__name__)
def dialect_insert(session: AsyncSession, table: type[TableBase]) -> PostgresInsert | SqliteInsert:
"""Return a dialect-specific INSERT construct that supports ``ON CONFLICT DO UPDATE``.
Production runs on PostgreSQL while tests run on SQLite; both support upserts with
compatible SQLAlchemy constructs, so the correct one is chosen from the bound dialect.
Args:
session (AsyncSession): Active database session whose bind selects the dialect.
table (type[TableBase]): Mapped table to insert into.
Returns:
PostgresInsert | SqliteInsert: A dialect insert exposing ``on_conflict_do_update``.
"""
if session.get_bind().dialect.name == "sqlite":
return sqlite_insert(table)
return pg_insert(table)
async def load_book_text(session: AsyncSession, book_id: int) -> str:
"""Load a book's indexed chunk text as one string for phrase extraction.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose chunk text is loaded.
Returns:
str: The book's chunk text joined into a single string.
"""
texts = await session.scalars(
select(EbookChunk.text).where(EbookChunk.source_id == book_id).order_by(EbookChunk.chunk_index)
)
return "\n\n".join(stripped for text in texts if (stripped := text.strip()))
async def load_book_chapter_texts(session: AsyncSession, book_id: int) -> list[str]:
"""Reconstruct chapter-like text blocks from indexed chunks for phrase extraction.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose chunks are grouped into chapters.
Returns:
list[str]: Non-empty chapter-like text blocks in chunk order.
"""
rows = await session.execute(
select(EbookChunk.chapter_id, EbookChunk.text)
.where(EbookChunk.source_id == book_id)
.order_by(EbookChunk.chunk_index)
)
chapters: list[str] = []
current_chapter_id: int | None = None
current_parts: list[str] = []
have_current = False
for chapter_id, text in rows:
if have_current and chapter_id != current_chapter_id:
chapter_text = "\n\n".join(current_parts).strip()
if chapter_text:
chapters.append(chapter_text)
current_parts = []
current_chapter_id = chapter_id
current_parts.append(str(text))
have_current = True
if current_parts:
chapter_text = "\n\n".join(current_parts).strip()
if chapter_text:
chapters.append(chapter_text)
return chapters
def metadata_for_source(source: EbookSource) -> dict[str, object | None]:
"""Return phrase extraction metadata for one indexed source.
Args:
source (EbookSource): Indexed source to read metadata from.
Returns:
dict[str, object | None]: Title, author, language, publisher, and identifier values.
"""
return {
"title": source.title,
"author": source.author,
"language": source.language,
"publisher": source.publisher,
"identifier": source.identifier,
}
async def metadata_for_source_id(session: AsyncSession, source_id: int) -> dict[str, object | None]:
"""Return phrase extraction metadata for one indexed source by id.
Args:
session (AsyncSession): Active database session.
source_id (int): Id of the indexed source to read metadata from.
Returns:
dict[str, object | None]: Title, author, language, publisher, and identifier values.
Raises:
ValueError: If no source exists with the given id.
"""
source = await session.get(EbookSource, source_id)
if source is None:
msg = f"No indexed source with id {source_id}"
raise ValueError(msg)
return metadata_for_source(source)
async def count_protected_phrases(session: AsyncSession, book_id: int) -> int:
"""Count stored protected phrases for one book.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose protected phrases are counted.
Returns:
int: Number of protected phrases stored for the book.
"""
return (
await session.scalars(
select(func.count(EbookProtectedPhrase.id)).where(EbookProtectedPhrase.book_id == book_id)
)
).one()
async def count_unjudged_candidates(session: AsyncSession, book_id: int, config: EbookSearchConfig) -> int:
"""Count storable candidate rows for a book that have not yet been judged.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose unjudged candidates are counted.
config (EbookSearchConfig): Runtime phrase-tuning settings supplying storage thresholds.
Returns:
int: Number of storable, unjudged candidate rows for the book.
"""
return (
await session.scalars(
select(func.count(EbookCandidatePhrase.id)).where(
EbookCandidatePhrase.book_id == book_id,
EbookCandidatePhrase.llm_judged.is_(False),
EbookCandidatePhrase.token_count >= config.phrase_min_tokens,
EbookCandidatePhrase.raw_count >= minimum_candidate_raw_count(config),
)
)
).one()
async def corpus_phrase_stats(session: AsyncSession) -> CorpusPhraseStats:
"""Summarize candidate and protected phrase coverage across the whole corpus.
Args:
session (AsyncSession): Active database session.
Returns:
CorpusPhraseStats: Corpus-wide phrase counts and per-book coverage counts.
"""
total_books = (await session.scalars(select(func.count(EbookSource.id)))).one()
candidate_phrases, judged_candidates, books_with_candidates, books_with_unjudged = (
await session.execute(
select(
func.count(EbookCandidatePhrase.id),
func.count(EbookCandidatePhrase.id).filter(EbookCandidatePhrase.llm_judged.is_(True)),
func.count(func.distinct(EbookCandidatePhrase.book_id)),
func.count(func.distinct(EbookCandidatePhrase.book_id)).filter(
EbookCandidatePhrase.llm_judged.is_(False)
),
)
)
).one()
protected_phrases = (await session.scalars(select(func.count(EbookProtectedPhrase.id)))).one()
return CorpusPhraseStats(
total_books=total_books,
books_with_candidates=books_with_candidates,
books_fully_judged=books_with_candidates - books_with_unjudged,
candidate_phrases=candidate_phrases,
judged_candidates=judged_candidates,
unjudged_candidates=candidate_phrases - judged_candidates,
protected_phrases=protected_phrases,
)
async def book_ids_pending_first_judgment(session: AsyncSession) -> list[int]:
"""Return books that have candidate phrases but no judged candidates yet.
Args:
session (AsyncSession): Active database session.
Returns:
list[int]: Book ids with candidates where judging has never run, ordered by id.
"""
judged_books = select(EbookCandidatePhrase.book_id).where(EbookCandidatePhrase.llm_judged.is_(True)).distinct()
return list(
(
await session.scalars(
select(EbookCandidatePhrase.book_id)
.where(EbookCandidatePhrase.book_id.not_in(judged_books))
.distinct()
.order_by(EbookCandidatePhrase.book_id)
)
).all()
)
async def load_candidates_for_judgment(
session: AsyncSession,
book_id: int,
config: EbookSearchConfig,
) -> Sequence[EbookCandidatePhrase]:
"""Load every storable unjudged candidate row for a book.
Rows may have been stored before the current junk filters and score weights existed, so
callers re-check :func:`is_junk_phrase` and rescore before selecting what to judge.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose candidates are loaded.
config (EbookSearchConfig): Runtime phrase-tuning settings supplying storage thresholds.
Returns:
Sequence[EbookCandidatePhrase]: Storable, unjudged candidate rows ordered by stored score.
"""
query = (
select(EbookCandidatePhrase)
.where(
EbookCandidatePhrase.book_id == book_id,
EbookCandidatePhrase.llm_judged.is_(False),
EbookCandidatePhrase.token_count >= config.phrase_min_tokens,
EbookCandidatePhrase.raw_count >= minimum_candidate_raw_count(config),
)
.order_by(
EbookCandidatePhrase.candidate_score.desc(),
EbookCandidatePhrase.raw_count.desc(),
EbookCandidatePhrase.id,
)
)
return (await session.scalars(query)).all()
def phrase_candidate_from_row(row: EbookCandidatePhrase) -> PhraseCandidate:
"""Recreate an in-memory candidate from a persisted candidate row.
Args:
row (EbookCandidatePhrase): Stored candidate row to convert.
Returns:
PhraseCandidate: An in-memory candidate mirroring the row's fields.
"""
return PhraseCandidate(
phrase_text=row.phrase_text,
phrase_norm=row.phrase_norm,
token_count=row.token_count,
source_raw_ngram=row.source_raw_ngram,
source_yake=row.source_yake,
source_capitalized=row.source_capitalized,
source_metadata=row.source_metadata,
raw_count=row.raw_count,
chapter_count=row.chapter_count,
yake_score=row.yake_score,
candidate_score=row.candidate_score,
sample_contexts=row.sample_contexts or [],
)
def candidate_row_values(
book_id: int,
series_id: int | None,
candidate: PhraseCandidate,
*,
judgment: LLMJudgment | None,
) -> dict[str, object]:
"""Build the column values for one candidate phrase upsert.
Args:
book_id (int): Book the candidate belongs to.
series_id (int | None): Series scope stored on the row.
candidate (PhraseCandidate): Candidate whose fields are written to the row.
judgment (LLMJudgment | None): Judgment to record, or ``None`` to leave the row unjudged.
Returns:
dict[str, object]: Column values keyed by column name.
"""
values: dict[str, object] = {
"book_id": book_id,
"phrase_norm": candidate.phrase_norm,
"series_id": series_id,
"phrase_text": candidate.phrase_text,
"token_count": candidate.token_count,
"source_raw_ngram": candidate.source_raw_ngram,
"source_yake": candidate.source_yake,
"source_capitalized": candidate.source_capitalized,
"source_metadata": candidate.source_metadata,
"raw_count": candidate.raw_count,
"chapter_count": candidate.chapter_count,
"yake_score": candidate.yake_score,
"candidate_score": candidate.candidate_score,
"llm_judged": judgment is not None,
}
if candidate.sample_contexts:
values["sample_contexts"] = list(candidate.sample_contexts)
if judgment is not None:
values.update(
llm_keep=judgment.keep,
llm_confidence=judgment.confidence,
llm_category=judgment.category,
llm_reason=judgment.reason,
)
return values
async def save_candidate_to_db(
session: AsyncSession,
book_id: int,
series_id: int | None,
candidate: PhraseCandidate,
*,
judgment: LLMJudgment | None,
) -> EbookCandidatePhrase:
"""Insert or update one candidate phrase row.
Args:
session (AsyncSession): Active database session.
book_id (int): Book the candidate belongs to.
series_id (int | None): Series scope stored on the row.
candidate (PhraseCandidate): Candidate whose fields are written to the row.
judgment (LLMJudgment | None): Judgment to record, or ``None`` to leave the row unjudged.
Returns:
EbookCandidatePhrase: The inserted or updated candidate row.
"""
values = candidate_row_values(book_id, series_id, candidate, judgment=judgment)
# Preserve an existing judgment when this call is only refreshing candidate fields.
skip_update = {"book_id", "phrase_norm"}
if judgment is None:
skip_update.add("llm_judged")
insert_statement = dialect_insert(session, EbookCandidatePhrase).values(**values)
statement = insert_statement.on_conflict_do_update(
index_elements=["book_id", "phrase_norm"],
set_={column: insert_statement.excluded[column] for column in values if column not in skip_update},
).returning(EbookCandidatePhrase)
return (await session.scalars(statement, execution_options={"populate_existing": True})).one()
BULK_CANDIDATE_UPSERT_CHUNK = 1000
async def bulk_upsert_unjudged_candidates(
session: AsyncSession,
book_id: int,
series_id: int | None,
candidates: Sequence[PhraseCandidate],
) -> int:
"""Insert or update many freshly extracted candidate rows in chunked multi-row upserts.
Saving one row per statement costs one database round trip per candidate, which dominated
generation time for full books, so candidates are written ``BULK_CANDIDATE_UPSERT_CHUNK``
rows per statement instead. Existing judgments and sample contexts are never overwritten:
fresh extractions carry no contexts, and ``llm_judged`` plus the ``llm_*`` columns are left
out of the conflict update. Candidates must have unique ``phrase_norm`` values, as produced
by extraction, since one multi-row upsert cannot touch the same row twice.
Args:
session (Session): Active database session.
book_id (int): Book the candidates belong to.
series_id (int | None): Series scope stored on the rows.
candidates (Sequence[PhraseCandidate]): Freshly extracted candidates to persist.
Returns:
int: Number of candidate rows written.
"""
values = [
candidate_row_values(book_id, series_id, candidate, judgment=None)
for candidate in candidates
if not candidate.sample_contexts
]
if len(values) != len(candidates):
msg = "bulk_upsert_unjudged_candidates only accepts freshly extracted candidates without sample contexts"
raise ValueError(msg)
skip_update = {"book_id", "phrase_norm", "llm_judged"}
for chunk_start in range(0, len(values), BULK_CANDIDATE_UPSERT_CHUNK):
chunk = values[chunk_start : chunk_start + BULK_CANDIDATE_UPSERT_CHUNK]
insert_statement = dialect_insert(session, EbookCandidatePhrase).values(chunk)
statement = insert_statement.on_conflict_do_update(
index_elements=["book_id", "phrase_norm"],
set_={column: insert_statement.excluded[column] for column in chunk[0] if column not in skip_update},
)
await session.execute(statement)
return len(values)
def new_candidate_row(book_id: int, series_id: int | None, candidate: PhraseCandidate) -> EbookCandidatePhrase:
"""Build a fresh unjudged candidate row without checking for an existing one.
Unlike :func:`save_candidate_to_db`, this does no lookup, so it is only safe when the caller
guarantees there is no existing row for ``(book_id, candidate.phrase_norm)`` — for example
right after :func:`delete_phrase_data_for_book` has cleared the book.
Args:
book_id (int): Book the candidate belongs to.
series_id (int | None): Series scope stored on the row.
candidate (PhraseCandidate): Candidate whose fields are written to the row.
Returns:
EbookCandidatePhrase: A new, unattached candidate row.
"""
row = EbookCandidatePhrase(book_id=book_id, phrase_norm=candidate.phrase_norm)
row.llm_judged = False
row.series_id = series_id
row.phrase_text = candidate.phrase_text
row.token_count = candidate.token_count
row.source_raw_ngram = candidate.source_raw_ngram
row.source_yake = candidate.source_yake
row.source_capitalized = candidate.source_capitalized
row.source_metadata = candidate.source_metadata
row.raw_count = candidate.raw_count
row.chapter_count = candidate.chapter_count
row.yake_score = candidate.yake_score
row.candidate_score = candidate.candidate_score
if candidate.sample_contexts:
row.sample_contexts = list(candidate.sample_contexts)
return row
async def upsert_protected_phrase(
session: AsyncSession,
book_id: int,
series_id: int | None,
candidate: PhraseCandidate,
judgment: LLMJudgment,
source_candidate: EbookCandidatePhrase,
) -> EbookProtectedPhrase:
"""Insert or update one accepted protected phrase and its aliases.
Args:
session (AsyncSession): Active database session.
book_id (int): Book the protected phrase belongs to.
series_id (int | None): Series scope stored on the phrase.
candidate (PhraseCandidate): Candidate the phrase was promoted from.
judgment (LLMJudgment): Accepted judgment supplying canonical text, category, and aliases.
source_candidate (EbookCandidatePhrase): Candidate row the phrase was promoted from.
Returns:
EbookProtectedPhrase: The inserted or updated protected phrase row.
Raises:
ValueError: If the chosen phrase text normalizes to empty.
"""
phrase_text = judgment.canonical or candidate.phrase_text
phrase_norm = normalize_text(phrase_text)
if not phrase_norm:
msg = f"Protected phrase normalized to empty text: {phrase_text!r}"
raise ValueError(msg)
values = {
"book_id": book_id,
"phrase_norm": phrase_norm,
"series_id": series_id,
"phrase_text": phrase_text,
"canonical_id": make_canonical_id(judgment, phrase_norm),
"phrase_type": judgment.category,
"token_count": len(phrase_norm.split()),
"confidence": judgment.confidence,
"importance": judgment.importance,
"allow_nested": judgment.allow_nested,
"suppress_children": judgment.suppress_children,
"source_candidate_id": source_candidate.id,
}
insert_statement = dialect_insert(session, EbookProtectedPhrase).values(**values)
statement = insert_statement.on_conflict_do_update(
index_elements=["book_id", "phrase_norm"],
set_={
column: insert_statement.excluded[column] for column in values if column not in {"book_id", "phrase_norm"}
},
).returning(EbookProtectedPhrase)
row = (await session.scalars(statement, execution_options={"populate_existing": True})).one()
for alias_text in judgment.aliases:
await upsert_phrase_alias(session, row, alias_text)
return row
async def upsert_phrase_alias(
session: AsyncSession,
phrase: EbookProtectedPhrase,
alias_text: str,
) -> EbookPhraseAlias | None:
"""Insert or update one protected phrase alias.
Args:
session (AsyncSession): Active database session.
phrase (EbookProtectedPhrase): Protected phrase the alias points to.
alias_text (str): Alias surface form to store.
Returns:
EbookPhraseAlias | None: The alias row, or ``None`` when the alias is empty or equals the phrase.
"""
alias_norm = normalize_text(alias_text)
if not alias_norm or alias_norm == phrase.phrase_norm:
return None
insert_statement = dialect_insert(session, EbookPhraseAlias).values(
phrase_id=phrase.id,
alias_norm=alias_norm,
alias_text=alias_text,
confidence=1.0,
)
statement = insert_statement.on_conflict_do_update(
index_elements=["phrase_id", "alias_norm"],
set_={
"alias_text": insert_statement.excluded.alias_text,
"confidence": insert_statement.excluded.confidence,
},
).returning(EbookPhraseAlias)
return (await session.scalars(statement, execution_options={"populate_existing": True})).one()
def make_canonical_id(judgment: LLMJudgment, phrase_norm: str) -> str:
"""Create a deterministic canonical id from a judgment category and phrase.
Args:
judgment (LLMJudgment): Judgment supplying the phrase category.
phrase_norm (str): Normalized phrase text to slugify.
Returns:
str: A ``category:slug`` canonical identifier.
"""
category = slugify_identifier(judgment.category or "phrase")
phrase_slug = slugify_identifier(phrase_norm)
return f"{category}:{phrase_slug}"
def slugify_identifier(value: str) -> str:
"""Normalize text for use inside a canonical id.
Args:
value (str): Text to slugify.
Returns:
str: A lowercase underscore slug, or ``"unknown"`` when empty.
"""
slug = re.sub(r"[^a-z0-9]+", "_", normalize_text(value).replace("'", ""))
return slug.strip("_") or "unknown"
async def prune_unstorable_unjudged_candidate_phrases(
session: AsyncSession,
book_id: int,
config: EbookSearchConfig,
) -> int:
"""Delete old unjudged candidate rows that no longer satisfy storage filters.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose stale candidates are pruned.
config (EbookSearchConfig): Runtime phrase-tuning settings supplying storage thresholds.
Returns:
int: Number of candidate rows deleted.
"""
deleted = rowcount(
await session.execute(
delete(EbookCandidatePhrase).where(
EbookCandidatePhrase.book_id == book_id,
EbookCandidatePhrase.llm_judged.is_(False),
or_(
EbookCandidatePhrase.token_count < config.phrase_min_tokens,
EbookCandidatePhrase.raw_count < minimum_candidate_raw_count(config),
),
)
)
)
if deleted:
logger.info(
f"ebook_candidate_phrase_unstorable_pruned {book_id=} {deleted=} {config.phrase_min_tokens=} "
f"min_uses={minimum_candidate_raw_count(config)}"
)
return deleted
async def delete_phrase_data_for_book(session: AsyncSession, book_id: int) -> PhraseRecalculationResult:
"""Delete all candidate, protected, alias, and mention phrase data for one book.
Args:
session (AsyncSession): Active database session.
book_id (int): Book whose phrase data is deleted.
Returns:
PhraseRecalculationResult: Deleted-row counts with ``candidate_phrases`` set to 0.
"""
protected_ids = (
await session.scalars(select(EbookProtectedPhrase.id).where(EbookProtectedPhrase.book_id == book_id))
).all()
deleted_aliases = 0
if protected_ids:
deleted_aliases = rowcount(
await session.execute(delete(EbookPhraseAlias).where(EbookPhraseAlias.phrase_id.in_(protected_ids)))
)
deleted_mentions = rowcount(
await session.execute(delete(EbookChunkPhraseMention).where(EbookChunkPhraseMention.book_id == book_id))
)
if protected_ids:
deleted_mentions += rowcount(
await session.execute(
delete(EbookChunkPhraseMention).where(EbookChunkPhraseMention.phrase_id.in_(protected_ids))
)
)
deleted_protected = rowcount(
await session.execute(delete(EbookProtectedPhrase).where(EbookProtectedPhrase.book_id == book_id))
)
deleted_candidates = rowcount(
await session.execute(delete(EbookCandidatePhrase).where(EbookCandidatePhrase.book_id == book_id))
)
await session.flush()
logger.info(
f"ebook_candidate_phrase_data_deleted {book_id=} {deleted_candidates=} {deleted_protected=} {deleted_aliases=} "
f"{deleted_mentions=}"
)
return PhraseRecalculationResult(
book_id=book_id,
deleted_candidates=deleted_candidates,
deleted_protected_phrases=deleted_protected,
deleted_aliases=deleted_aliases,
deleted_mentions=deleted_mentions,
candidate_phrases=0,
)
def rowcount(result: object) -> int:
"""Return a safe integer rowcount from a SQLAlchemy execution result.
Args:
result (object): SQLAlchemy execution result that may expose ``rowcount``.
Returns:
int: The result's rowcount, or 0 when it is missing or negative.
"""
count = getattr(result, "rowcount", 0)
return int(count if count is not None and count >= 0 else 0)
@@ -1,91 +0,0 @@
"""Protected phrase extraction, storage, and runtime matching."""
from __future__ import annotations
import re
from dataclasses import dataclass
JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
@dataclass(frozen=True, slots=True)
class NormalizedToken:
"""A normalized token plus its source character span."""
text: str
start_char: int
end_char: int
def normalize_text(text: str) -> str:
"""Normalize text for phrase storage and lookup.
Args:
text (str): Raw text to normalize.
Returns:
str: Normalized tokens joined by single spaces.
"""
return " ".join(token.text for token in tokenize_with_offsets(text))
def tokenize(text: str) -> list[str]:
"""Normalize and split text into phrase-detection tokens.
Args:
text (str): Raw text to tokenize.
Returns:
list[str]: Normalized token strings.
"""
return [token.text for token in tokenize_with_offsets(text)]
def tokenize_with_offsets(text: str) -> list[NormalizedToken]:
"""Normalize text into tokens while preserving original character offsets.
Args:
text (str): Raw text to tokenize.
Returns:
list[NormalizedToken]: Normalized tokens with their source character spans.
"""
tokens: list[NormalizedToken] = []
current: list[str] = []
start_char: int | None = None
for index, char in enumerate(text):
normalized = normalize_char(char)
if normalized == " ":
if current and start_char is not None:
tokens.append(NormalizedToken(text="".join(current), start_char=start_char, end_char=index))
current = []
start_char = None
continue
if start_char is None:
start_char = index
current.append(normalized)
if current and start_char is not None:
tokens.append(NormalizedToken(text="".join(current), start_char=start_char, end_char=len(text)))
return tokens
def normalize_char(char: str) -> str:
"""Normalize one character into a token character or a separator.
Args:
char (str): Single source character to normalize.
Returns:
str: The normalized token character, or a space acting as a separator.
"""
if char in {"\u2019", "\u2018"}:
return "'"
if char in {"-", "\u2013", "\u2014"}:
return " "
lowered = char.lower()
if lowered in "abcdefghijklmnopqrstuvwxyz0123456789'":
return lowered
return " "
+17 -15
View File
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING
from python.ebook_search.llm_interface import request_rerank
if TYPE_CHECKING:
import httpx
from python.ebook_search.config import RerankConfig
from python.ebook_search.search import SearchResult
@@ -25,18 +23,18 @@ class RerankResult:
score: float
async def rerank_chunks(
client: httpx.AsyncClient,
query: str,
candidates: list[SearchResult],
config: RerankConfig,
) -> list[SearchResult]:
def rerank_chunks(query: str, candidates: list[SearchResult], config: RerankConfig) -> list[SearchResult]:
"""Rerank candidates with a vLLM rerank endpoint."""
if not candidates:
return []
logger.info(f"ebook_rerank_request_start {config.base_url=} {config.model=} candidates={len(candidates)}")
scores = await score_candidates(client, query, candidates, config)
logger.info(
"ebook_rerank_request_start base_url=%s model=%s candidates=%s",
config.base_url,
config.model,
len(candidates),
)
scores = score_candidates(query, candidates, config)
results = sorted(
(
replace(
@@ -49,24 +47,28 @@ async def rerank_chunks(
key=lambda result: result.score,
reverse=True,
)
logger.info(f"ebook_rerank_request_complete {config.base_url=} {config.model=} candidates={len(results)}")
logger.info(
"ebook_rerank_request_complete base_url=%s model=%s candidates=%s",
config.base_url,
config.model,
len(results),
)
return results
async def score_candidates(
client: httpx.AsyncClient,
def score_candidates(
query: str,
candidates: list[SearchResult],
config: RerankConfig,
) -> dict[int, RerankResult]:
"""Score candidate chunks with the configured rerank API."""
body = await request_rerank(client, query, [candidate.text for candidate in candidates], config)
body = request_rerank(query, [candidate.text for candidate in candidates], config)
if body is None:
return zero_rerank_scores(candidates)
scores = parse_vllm_scores(body, candidates)
for result in scores.values():
logger.debug(f"ebook_rerank_candidate_scored {result.chunk_id=} {result.score=}")
logger.debug("ebook_rerank_candidate_scored chunk_id=%s score=%s", result.chunk_id, result.score)
return scores
+78 -178
View File
@@ -2,31 +2,25 @@
from __future__ import annotations
import asyncio
import logging
import re
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from pgvector.sqlalchemy import Vector
from sqlalchemy import literal, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.bm25_corpus import (
BM25CorpusUnavailableError,
load_bm25_corpus,
score_bm25_corpus,
)
from python.ebook_search.chunk_records import CHUNK_RECORD_COLUMNS
from python.ebook_search.embeddings import MODEL_DIMENSIONS, embed_query, get_embedding_table
from python.ebook_search.protected_phrases.matching import (
detect_protected_phrases_for_query,
phrase_hits_for_chunks,
)
from python.ebook_search.rerank import rerank_chunks
from python.ebook_search.timing import RuntimeStep, async_timed_result, timed_result
from python.ebook_search.timing import RuntimeStep, timed_result
from python.orm.richie import (
EbookChapter,
EbookChunk,
@@ -35,13 +29,11 @@ from python.orm.richie import (
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
import httpx
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Engine
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.protected_phrases.models import PhraseMatch
logger = logging.getLogger(__name__)
@@ -53,14 +45,11 @@ class SearchResult:
chunk_id: int
text: str
source_title: str
source_id: int | None = None
score: float = 0.0
vector_score: float | None = None
bm25_score: float | None = None
fused_score: float | None = None
rerank_score: float | None = None
phrase_hit_count: int = 0
matched_phrases: tuple[str, ...] = ()
source_author: str | None = None
chapter_title: str | None = None
page_label: str | None = None
@@ -75,7 +64,6 @@ class SearchResponse:
results: list[SearchResult]
rank_label: str
timings: tuple[RuntimeStep, ...] = ()
phrase_matches: tuple[PhraseMatch, ...] = ()
@property
def total_runtime_ms(self) -> float:
@@ -85,40 +73,34 @@ class SearchResponse:
@dataclass(frozen=True)
class RetrievalResponse:
"""Parallel retrieval output for vector, BM25, and protected phrase candidates."""
"""Parallel retrieval output for vector and BM25 candidates."""
vector_results: list[SearchResult]
lexical_results: list[SearchResult]
phrase_matches: list[PhraseMatch]
timings: tuple[RuntimeStep, ...]
async def search_ebooks(
engine: AsyncEngine,
client: httpx.AsyncClient,
def search_ebooks(
engine: Engine,
query: str,
config: EbookSearchConfig,
*,
rerank: bool,
phrase_matching: bool,
rerank: bool = False,
) -> SearchResponse:
"""Run hybrid vector/BM25 search and optional reranking.
Phrase matching only runs when both the request asks for it and
``config.phrase_matching_enabled`` allows it.
"""
"""Run hybrid vector/BM25 search and optional reranking."""
if not query.strip():
logger.info("ebook_search_empty_query")
return SearchResponse(query=query, results=[], rank_label="Hybrid")
phrase_matching = phrase_matching and config.phrase_matching_enabled
logger.info(f"ebook_search_start query_length={len(query)} {rerank=} {phrase_matching=}")
logger.info("ebook_search_start query_length=%s rerank=%s", len(query), rerank)
timings: list[RuntimeStep] = []
retrieval, timing = await async_timed_result(
retrieval, timing = timed_result(
"Hybrid retrieval",
parallel_retrieval(engine, client, query, config, phrase_matching=phrase_matching),
parallel_retrieval,
engine,
query,
config,
)
phrase_matches = retrieval.phrase_matches
timings.extend(retrieval.timings)
timings.append(timing)
fused, timing = timed_result(
@@ -129,147 +111,61 @@ async def search_ebooks(
rank_constant=config.rrf_rank_constant,
)
timings.append(timing)
if phrase_matching:
fused, timing = await async_timed_result(
"Phrase mention boost",
apply_phrase_mention_boosts(engine, fused, phrase_matches, config.phrase_hit_boost),
)
else:
fused, timing = timed_result("Phrase mention boost skipped", skip_phrase_mention_boosts, fused)
timings.append(timing)
if config.rerank.enabled and rerank:
response, timing = await async_timed_result("Rerank", apply_rerank(client, query, fused, config))
response, timing = timed_result("Rerank", apply_rerank, query, fused, config)
else:
response, timing = timed_result("Rerank skipped", skip_rerank, query, fused, config)
timings.append(timing)
response = replace(response, timings=tuple(timings), phrase_matches=tuple(phrase_matches))
response = replace(response, timings=tuple(timings))
logger.info(
f"ebook_search_complete vector_candidates={len(retrieval.vector_results)} "
f"lexical_candidates={len(retrieval.lexical_results)} fused_candidates={len(fused)} {phrase_matching=} "
f"phrase_matches={len(phrase_matches)} returned={len(response.results)} {response.rank_label=} "
f"{response.total_runtime_ms=:.1f}"
"ebook_search_complete vector_candidates=%s lexical_candidates=%s "
"fused_candidates=%s returned=%s rank_label=%s runtime_ms=%.1f",
len(retrieval.vector_results),
len(retrieval.lexical_results),
len(fused),
len(response.results),
response.rank_label,
response.total_runtime_ms,
)
return response
async def query_phrase_matches(
engine: AsyncEngine,
def parallel_retrieval(
engine: Engine,
query: str,
config: EbookSearchConfig,
*,
phrase_matching: bool,
) -> list[PhraseMatch]:
"""Detect protected phrases in a query without making search fail when phrase tables are unavailable."""
if not phrase_matching:
logger.info("ebook_protected_phrase_detection_skipped")
return []
try:
async with AsyncSession(engine) as session:
return await detect_protected_phrases_for_query(session, query, config)
except SQLAlchemyError as error:
logger.warning(f"ebook_protected_phrase_detection_unavailable {error=}")
return []
def skip_phrase_mention_boosts(candidates: list[SearchResult]) -> list[SearchResult]:
"""Return candidates unchanged when phrase matching is disabled."""
logger.info(f"ebook_phrase_boost_skipped candidates={len(candidates)}")
return candidates
async def apply_phrase_mention_boosts(
engine: AsyncEngine,
candidates: list[SearchResult],
phrase_matches: Sequence[PhraseMatch],
phrase_hit_boost: float,
) -> list[SearchResult]:
"""Boost retrieved chunks that have indexed mentions for detected protected phrases."""
phrase_ids = sorted({match.phrase_id for match in phrase_matches})
if not candidates or not phrase_ids or phrase_hit_boost <= 0:
return candidates
chunk_ids = [candidate.chunk_id for candidate in candidates]
try:
async with AsyncSession(engine) as session:
phrase_hits = await phrase_hits_for_chunks(session, chunk_ids=chunk_ids, phrase_ids=phrase_ids)
except SQLAlchemyError as error:
logger.warning(f"ebook_phrase_boost_unavailable {error=}")
return candidates
if not phrase_hits:
return candidates
hit_counts = {
chunk_id: sum(hit.mention_count for hit in chunk_hits) for chunk_id, chunk_hits in phrase_hits.items()
}
boosted = [
replace(
candidate,
score=candidate.score + (hit_counts.get(candidate.chunk_id, 0) * phrase_hit_boost),
fused_score=boosted_fused_score(candidate, hit_counts.get(candidate.chunk_id, 0), phrase_hit_boost),
phrase_hit_count=hit_counts.get(candidate.chunk_id, 0),
matched_phrases=tuple(hit.phrase_text for hit in phrase_hits.get(candidate.chunk_id, ())),
rank_source=phrase_rank_source(candidate.rank_source, hit_counts.get(candidate.chunk_id, 0)),
)
for candidate in candidates
]
return sorted(boosted, key=lambda candidate: candidate.score, reverse=True)
def boosted_fused_score(candidate: SearchResult, phrase_hit_count: int, phrase_hit_boost: float) -> float | None:
"""Return a fused score adjusted by phrase hits when a fused score exists."""
if candidate.fused_score is None:
return None
return candidate.fused_score + (phrase_hit_count * phrase_hit_boost)
def phrase_rank_source(rank_source: str, phrase_hit_count: int) -> str:
"""Append phrase evidence to a rank-source label when a chunk was boosted."""
if phrase_hit_count <= 0 or "phrases" in rank_source:
return rank_source
return f"{rank_source} + phrases"
async def parallel_retrieval(
engine: AsyncEngine,
client: httpx.AsyncClient,
query: str,
config: EbookSearchConfig,
*,
phrase_matching: bool,
) -> RetrievalResponse:
"""Run vector, BM25, and protected phrase retrieval concurrently with separate database sessions.
BM25 scoring is pure CPU work over the cached corpus, so it runs in a worker thread
instead of on the event loop. Protected phrase detection only depends on the query, so
it joins the gather as a third task and returns immediately when phrase matching is disabled.
"""
phrase_timing_name = "Protected phrase detection" if phrase_matching else "Protected phrase detection skipped"
(
(vector_results, vector_timing),
(lexical_results, lexical_timing),
(phrase_matches, phrase_timing),
) = await asyncio.gather(
async_timed_result("Embedding + vector search", vector_candidates(engine, client, query, config)),
async_timed_result("BM25 search", asyncio.to_thread(bm25_candidates, query, config)),
async_timed_result(
phrase_timing_name,
query_phrase_matches(engine, query, config, phrase_matching=phrase_matching),
),
)
"""Run vector and BM25 candidate retrieval concurrently with separate database sessions."""
with ThreadPoolExecutor(max_workers=2, thread_name_prefix="ebook-search") as executor:
vector_future = executor.submit(
timed_result,
"Embedding + vector search",
vector_candidates,
engine,
query,
config,
)
bm25_future = executor.submit(
timed_result,
"BM25 search",
bm25_candidates,
query,
config,
)
vector_results, vector_timing = vector_future.result()
lexical_results, lexical_timing = bm25_future.result()
logger.info(
f"ebook_parallel_retrieval_complete vector_candidates={len(vector_results)} "
f"lexical_candidates={len(lexical_results)} phrase_matches={len(phrase_matches)}"
"ebook_parallel_retrieval_complete vector_candidates=%s lexical_candidates=%s",
len(vector_results),
len(lexical_results),
)
return RetrievalResponse(
vector_results=vector_results,
lexical_results=lexical_results,
phrase_matches=phrase_matches,
timings=(
replace(vector_timing, counts_toward_total=False),
replace(lexical_timing, counts_toward_total=False),
replace(phrase_timing, counts_toward_total=False),
),
)
@@ -280,21 +176,21 @@ def skip_rerank(
config: EbookSearchConfig,
) -> SearchResponse:
"""Return fused hybrid results without reranking."""
logger.info(f"ebook_rerank_skipped candidates={len(candidates)}")
logger.info("ebook_rerank_skipped candidates=%s", len(candidates))
return SearchResponse(query=query, results=candidates[: config.top_k], rank_label="Hybrid")
async def apply_rerank(
client: httpx.AsyncClient,
def apply_rerank(
query: str,
candidates: list[SearchResult],
config: EbookSearchConfig,
) -> SearchResponse:
"""Rerank already-fused hybrid candidates."""
reranked = await rerank_chunks(client, query, candidates[: config.rerank.candidates], config.rerank)
reranked = rerank_chunks(query, candidates[: config.rerank.candidates], config.rerank)
logger.info(
f"ebook_rerank_complete input_candidates={min(len(candidates), config.rerank.candidates)} "
f"returned={len(reranked)}"
"ebook_rerank_complete input_candidates=%s returned=%s",
min(len(candidates), config.rerank.candidates),
len(reranked),
)
return SearchResponse(
query=query,
@@ -303,17 +199,10 @@ async def apply_rerank(
)
async def vector_candidates(
engine: AsyncEngine,
client: httpx.AsyncClient,
query: str,
config: EbookSearchConfig,
) -> list[SearchResult]:
def vector_candidates(engine: Engine, query: str, config: EbookSearchConfig) -> list[SearchResult]:
"""Return pgvector cosine candidates for a natural-language query."""
async with AsyncSession(engine) as session:
model = await session.scalar(
select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model)
)
with Session(engine) as session:
model = session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model))
if model is None:
msg = f"Embedding model is not registered: {config.embedding_model}"
raise ValueError(msg)
@@ -323,7 +212,7 @@ async def vector_candidates(
msg = f"Model row dimension {model.dimension} does not match configured dimension {expected_dimension}"
raise ValueError(msg)
embedding = await embed_query(client, query, config)
embedding = embed_query(query, config)
limit = max(config.rerank.candidates, config.top_k) * config.vector_candidate_multiplier
embedding_table = get_embedding_table(model.dimension)
@@ -332,7 +221,12 @@ async def vector_candidates(
score = (literal(1.0) - distance).label("score")
statement = (
select(
*CHUNK_RECORD_COLUMNS,
EbookChunk.id.label("chunk_id"),
EbookChunk.text.label("text"),
EbookSource.title.label("source_title"),
EbookSource.author.label("source_author"),
EbookChapter.title.label("chapter_title"),
EbookChunk.page_label.label("page_label"),
score,
)
.select_from(embedding_table)
@@ -343,10 +237,13 @@ async def vector_candidates(
.order_by(distance)
.limit(limit)
)
rows = (await session.execute(statement)).mappings()
rows = session.execute(statement).mappings()
results = [search_result_from_row(row) for row in rows]
logger.info(
f"ebook_vector_search_complete {config.embedding_model=} {model.dimension=} candidates={len(results)}"
"ebook_vector_search_complete model=%s dimension=%s candidates=%s",
config.embedding_model,
model.dimension,
len(results),
)
return results
@@ -356,7 +253,7 @@ def bm25_candidates(query: str, config: EbookSearchConfig) -> list[SearchResult]
try:
corpus = load_bm25_corpus(config)
except BM25CorpusUnavailableError as error:
logger.warning(f"ebook_bm25_index_unavailable_skipping {error=}")
logger.warning("ebook_bm25_index_unavailable_skipping error=%s", error)
return []
if not corpus.records:
@@ -371,7 +268,12 @@ def bm25_candidates(query: str, config: EbookSearchConfig) -> list[SearchResult]
]
max_score = results[0].bm25_score if results else 0.0
logger.info(f"ebook_bm25_search_complete corpus={len(corpus.records)} candidates={len(results)} {max_score=:.6f}")
logger.info(
"ebook_bm25_search_complete corpus=%s candidates=%s max_score=%.6f",
len(corpus.records),
len(results),
max_score,
)
return results
@@ -415,11 +317,9 @@ def reciprocal_rank_fusion(
def search_result_from_row(row: Mapping[str, object]) -> SearchResult:
"""Convert a database row mapping into a search result."""
source_id = row.get("source_id")
return SearchResult(
chunk_id=int(row["chunk_id"]),
text=str(row["text"]),
source_id=int(source_id) if source_id is not None else None,
source_title=str(row["source_title"]),
source_author=optional_str(row["source_author"]),
chapter_title=optional_str(row["chapter_title"]),
+1 -8
View File
@@ -7,7 +7,7 @@ from time import perf_counter
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from collections.abc import Callable
@dataclass(frozen=True)
@@ -34,10 +34,3 @@ def timed_result[T, **P](
start_seconds = perf_counter()
result = operation(*args, **kwargs)
return result, runtime_step_from_start(name, start_seconds)
async def async_timed_result[T](name: str, awaitable: Awaitable[T]) -> tuple[T, RuntimeStep]:
"""Await an operation and return its result plus elapsed runtime."""
start_seconds = perf_counter()
result = await awaitable
return result, runtime_step_from_start(name, start_seconds)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Reusable FastAPI tools."""
from python.fastapi_tools.db import AsyncDbSession, DbSession, get_async_db, get_db
from python.fastapi_tools.db import DbSession, get_db
from python.fastapi_tools.zstd_middleware import ZstdMiddleware
__all__ = ["AsyncDbSession", "DbSession", "ZstdMiddleware", "get_async_db", "get_db"]
__all__ = ["DbSession", "ZstdMiddleware", "get_db"]
+1 -13
View File
@@ -5,11 +5,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
from collections.abc import Iterator
def get_db(request: Request) -> Iterator[Session]:
@@ -18,15 +17,4 @@ def get_db(request: Request) -> Iterator[Session]:
yield session
async def get_async_db(request: Request) -> AsyncIterator[AsyncSession]:
"""Get an async database session from app state.
expire_on_commit=False keeps ORM attributes readable after commit without
triggering implicit IO, which would raise under asyncio.
"""
async with AsyncSession(request.app.state.engine, expire_on_commit=False) as session:
yield session
DbSession = Annotated[Session, Depends(get_db)]
AsyncDbSession = Annotated[AsyncSession, Depends(get_async_db)]
+12 -63
View File
@@ -7,7 +7,6 @@ from typing import cast
from sqlalchemy import create_engine
from sqlalchemy.engine import URL, Engine
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
NAMING_CONVENTION = {
"ix": "ix_%(table_name)s_%(column_0_name)s",
@@ -32,16 +31,23 @@ def get_connection_info(name: str) -> tuple[str, str, str, str, str | None]:
return cast("tuple[str, str, str, str, str | None]", (database, host, port, username, password))
def build_postgres_url(name: str, *, vector_engine: bool = False) -> tuple[URL, dict[str, str]]:
"""Build the Postgres connection URL and connect_args from environment variables.
def get_postgres_engine(
*,
name: str = "POSTGRES",
pool_pre_ping: bool = True,
vector_engine: bool = False,
) -> Engine:
"""Create a SQLAlchemy engine from environment variables.
Args:
name (str): The name of the environment variable prefix.
name (str, optional): The name of the environment variable prefix. Defaults to "POSTGRES".
pool_pre_ping (bool, optional): Whether to ping the database before each connection. Defaults to True.
This fixes the issue of trying to use a conection that has timed out on the database side.
vector_engine (bool, optional): Whether to use the vector search schema. Defaults to False.
This updates the search path to include the vector types and operators.
This updates the search path the incldued the vecore types and operators.
Returns:
tuple[URL, dict[str, str]]: The SQLAlchemy URL and connect_args for create_engine.
Engine: The SQLAlchemy engine.
"""
database, host, port, username, password = get_connection_info(name)
@@ -59,66 +65,9 @@ def build_postgres_url(name: str, *, vector_engine: bool = False) -> tuple[URL,
if vector_engine:
connect_args["options"] = "-csearch_path=main,public"
return url, connect_args
def get_postgres_engine(
*,
name: str = "POSTGRES",
pool_pre_ping: bool = True,
vector_engine: bool = False,
pool_size: int = 8,
) -> Engine:
"""Create a SQLAlchemy engine from environment variables.
Args:
name (str, optional): The name of the environment variable prefix. Defaults to "POSTGRES".
pool_pre_ping (bool, optional): Whether to ping the database before each connection. Defaults to True.
This fixes the issue of trying to use a conection that has timed out on the database side.
vector_engine (bool, optional): Whether to use the vector search schema. Defaults to False.
This updates the search path the incldued the vecore types and operators.
pool_size (int, optional): Number of connections to keep in the pool. Defaults to 8.
Returns:
Engine: The SQLAlchemy engine.
"""
url, connect_args = build_postgres_url(name, vector_engine=vector_engine)
return create_engine(
url=url,
pool_pre_ping=pool_pre_ping,
pool_recycle=1800,
connect_args=connect_args,
pool_size=pool_size,
)
def get_async_postgres_engine(
*,
name: str = "POSTGRES",
pool_pre_ping: bool = True,
vector_engine: bool = False,
pool_size: int = 8,
) -> AsyncEngine:
"""Create an async SQLAlchemy engine from environment variables.
Args:
name (str, optional): The name of the environment variable prefix. Defaults to "POSTGRES".
pool_pre_ping (bool, optional): Whether to ping the database before each connection. Defaults to True.
This fixes the issue of trying to use a conection that has timed out on the database side.
vector_engine (bool, optional): Whether to use the vector search schema. Defaults to False.
This updates the search path the incldued the vecore types and operators.
pool_size (int, optional): Number of connections to keep in the pool. Defaults to 8.
Returns:
AsyncEngine: The async SQLAlchemy engine.
"""
url, connect_args = build_postgres_url(name, vector_engine=vector_engine)
return create_async_engine(
url=url,
pool_pre_ping=pool_pre_ping,
pool_recycle=1800,
connect_args=connect_args,
pool_size=pool_size,
)
-8
View File
@@ -12,16 +12,12 @@ from python.orm.richie.contact import (
RelationshipType,
)
from python.orm.richie.ebook import (
EbookCandidatePhrase,
EbookChapter,
EbookChunk,
EbookChunkEmbedding1024,
EbookChunkEmbedding2560,
EbookChunkEmbedding4096,
EbookChunkPhraseMention,
EbookEmbeddingModel,
EbookPhraseAlias,
EbookProtectedPhrase,
EbookSource,
)
@@ -32,16 +28,12 @@ __all__ = [
"Contact",
"ContactNeed",
"ContactRelationship",
"EbookCandidatePhrase",
"EbookChapter",
"EbookChunk",
"EbookChunkEmbedding1024",
"EbookChunkEmbedding2560",
"EbookChunkEmbedding4096",
"EbookChunkPhraseMention",
"EbookEmbeddingModel",
"EbookPhraseAlias",
"EbookProtectedPhrase",
"EbookSource",
"Need",
"RelationshipType",
+2 -105
View File
@@ -5,23 +5,11 @@ from __future__ import annotations
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from python.orm.richie.base import TableBase, TableBaseBig
JSON_DOCUMENT = JSON().with_variant(JSONB, "postgresql")
class EbookSource(TableBase):
"""One indexed EPUB file."""
@@ -106,7 +94,7 @@ class EbookEmbeddingModel(TableBase):
name: Mapped[str] = mapped_column(String, unique=True)
dimension: Mapped[int]
is_default: Mapped[bool] = mapped_column(default=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
class EbookChunkEmbedding1024(TableBaseBig):
@@ -148,94 +136,3 @@ class EbookChunkEmbedding4096(TableBaseBig):
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
model_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_embedding_model.id", ondelete="CASCADE"))
embedding: Mapped[list[float]] = mapped_column(Vector(4096))
class EbookCandidatePhrase(TableBase):
"""A high-recall phrase candidate extracted from one book."""
__tablename__ = "candidate_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
Index("candidate_phrases_book_score_idx", "book_id", "candidate_score"),
Index("candidate_phrases_book_norm_idx", "book_id", "phrase_norm"),
)
book_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
token_count: Mapped[int]
source_raw_ngram: Mapped[bool] = mapped_column(default=False)
source_yake: Mapped[bool] = mapped_column(default=False)
source_capitalized: Mapped[bool] = mapped_column(default=False)
source_metadata: Mapped[bool] = mapped_column(default=False)
raw_count: Mapped[int] = mapped_column(default=0)
chapter_count: Mapped[int] = mapped_column(default=0)
yake_score: Mapped[float | None]
candidate_score: Mapped[float] = mapped_column(default=0.0)
sample_contexts: Mapped[list[str] | None] = mapped_column(JSON_DOCUMENT)
llm_judged: Mapped[bool] = mapped_column(default=False)
llm_keep: Mapped[bool | None]
llm_confidence: Mapped[float | None]
llm_category: Mapped[str | None]
llm_reason: Mapped[str | None] = mapped_column(Text)
class EbookProtectedPhrase(TableBase):
"""A phrase accepted by the LLM judge for protected query matching."""
__tablename__ = "protected_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
Index("protected_phrases_norm_idx", "phrase_norm"),
Index("protected_phrases_book_norm_idx", "book_id", "phrase_norm"),
Index("protected_phrases_series_norm_idx", "series_id", "phrase_norm"),
)
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
canonical_id: Mapped[str]
phrase_type: Mapped[str | None]
token_count: Mapped[int]
confidence: Mapped[float]
importance: Mapped[float] = mapped_column(default=0.5)
allow_nested: Mapped[bool] = mapped_column(default=False)
suppress_children: Mapped[bool] = mapped_column(default=True)
source_candidate_id: Mapped[int | None] = mapped_column(
ForeignKey("main.candidate_phrases.id", ondelete="SET NULL")
)
class EbookPhraseAlias(TableBase):
"""A normalized alias that maps to a protected phrase."""
__tablename__ = "phrase_aliases"
__table_args__ = (
UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
Index("phrase_aliases_norm_idx", "alias_norm"),
)
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
alias_text: Mapped[str] = mapped_column(Text)
alias_norm: Mapped[str] = mapped_column(Text)
confidence: Mapped[float] = mapped_column(default=1.0)
class EbookChunkPhraseMention(TableBase):
"""A precomputed occurrence of a protected phrase inside one chunk."""
__tablename__ = "chunk_phrase_mentions"
__table_args__ = (
UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
Index("chunk_phrase_mentions_phrase_idx", "phrase_id"),
Index("chunk_phrase_mentions_chunk_idx", "chunk_id"),
)
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
start_char: Mapped[int]
end_char: Mapped[int | None]
+37 -43
View File
@@ -10,8 +10,8 @@ from types import ModuleType
from typing import TYPE_CHECKING
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from python.ebook_search.answer import answer_query
from python.ebook_search.bm25_corpus import (
@@ -25,7 +25,7 @@ from python.ebook_search.bm25_corpus import (
score_bm25_corpus,
write_bm25_corpus,
)
from python.ebook_search.config import EbookSearchConfig, RerankConfig, load_config
from python.ebook_search.config import EbookSearchConfig, RerankConfig, load_config, normalize_embedding_model
from python.ebook_search.embeddings import MODEL_DIMENSIONS, ensure_embedding_models
from python.ebook_search.ingest import chunk_text, find_existing_source
from python.ebook_search.search import (
@@ -77,17 +77,10 @@ def test_reciprocal_rank_fusion_combines_vector_and_bm25_rankings() -> None:
assert fused[0].fused_score == fused[0].score
async def build_async_engine() -> AsyncEngine:
"""Create an in-memory async engine with the Richie schema."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as connection:
await connection.run_sync(RichieBase.metadata.create_all)
return engine
async def test_find_existing_source_matches_path_or_hash() -> None:
engine = await build_async_engine()
async with AsyncSession(engine, expire_on_commit=False) as session:
def test_find_existing_source_matches_path_or_hash() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
RichieBase.metadata.create_all(engine)
with sessionmaker(bind=engine, expire_on_commit=False, future=True)() as session:
source = EbookSource(
title="Book",
author=None,
@@ -100,15 +93,16 @@ async def test_find_existing_source_matches_path_or_hash() -> None:
file_size=10,
)
session.add(source)
await session.commit()
session.commit()
assert await find_existing_source(session, Path("/old/book.epub"), "b" * 64) == source
assert await find_existing_source(session, Path("/new/book.epub"), "a" * 64) == source
assert find_existing_source(session, Path("/old/book.epub"), "b" * 64) == source
assert find_existing_source(session, Path("/new/book.epub"), "a" * 64) == source
async def test_bm25_corpus_uses_existing_search_text_without_duplicate_metadata() -> None:
engine = await build_async_engine()
async with AsyncSession(engine, expire_on_commit=False) as session:
def test_bm25_corpus_uses_existing_search_text_without_duplicate_metadata() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
RichieBase.metadata.create_all(engine)
with sessionmaker(bind=engine, expire_on_commit=False, future=True)() as session:
source = EbookSource(
title="Book",
author="Author",
@@ -121,10 +115,10 @@ async def test_bm25_corpus_uses_existing_search_text_without_duplicate_metadata(
file_size=10,
)
session.add(source)
await session.flush()
session.flush()
chapter = EbookChapter(source_id=source.id, spine_index=0, title="Chapter", href=None)
session.add(chapter)
await session.flush()
session.flush()
session.add(
EbookChunk(
id=1,
@@ -139,9 +133,9 @@ async def test_bm25_corpus_uses_existing_search_text_without_duplicate_metadata(
search_text="Book Author Chapter content",
)
)
await session.commit()
session.commit()
records, texts = await fetch_bm25_corpus_records(session)
records, texts = fetch_bm25_corpus_records(session)
assert texts == ["Book Author Chapter content"]
assert records[0]["chunk_id"] == 1
@@ -376,7 +370,7 @@ def test_load_bm25_corpus_raises_when_index_is_missing(mocker: MockerFixture, tm
load_bm25_corpus.cache_clear()
async def test_ensure_bm25_corpus_refreshes_missing_index(mocker: MockerFixture) -> None:
def test_ensure_bm25_corpus_refreshes_missing_index(mocker: MockerFixture) -> None:
refreshed: list[object] = []
db_updated_at = datetime.now(tz=UTC)
@@ -391,12 +385,12 @@ async def test_ensure_bm25_corpus_refreshes_missing_index(mocker: MockerFixture)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
session = object()
await ensure_bm25_corpus(session, config)
ensure_bm25_corpus(session, config)
assert refreshed == [(session, config, db_updated_at)]
async def test_ensure_bm25_corpus_refreshes_stale_index(mocker: MockerFixture) -> None:
def test_ensure_bm25_corpus_refreshes_stale_index(mocker: MockerFixture) -> None:
refreshed: list[object] = []
created_at = datetime(2026, 1, 1, tzinfo=UTC)
db_updated_at = datetime(2026, 1, 2, tzinfo=UTC)
@@ -413,7 +407,7 @@ async def test_ensure_bm25_corpus_refreshes_stale_index(mocker: MockerFixture) -
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
session = object()
await ensure_bm25_corpus(session, config)
ensure_bm25_corpus(session, config)
assert refreshed == [(session, config, db_updated_at)]
@@ -426,13 +420,14 @@ def test_supported_embedding_models_match_service_names() -> None:
}
async def test_ensure_embedding_models_registers_service_names() -> None:
engine = await build_async_engine()
async with AsyncSession(engine, expire_on_commit=False) as session:
await ensure_embedding_models(session)
await session.commit()
def test_ensure_embedding_models_registers_service_names() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
RichieBase.metadata.create_all(engine)
with sessionmaker(bind=engine, expire_on_commit=False, future=True)() as session:
ensure_embedding_models(session)
session.commit()
models = list(await session.scalars(select(EbookEmbeddingModel).order_by(EbookEmbeddingModel.name)))
models = list(session.scalars(select(EbookEmbeddingModel).order_by(EbookEmbeddingModel.name)))
assert [(model.name, model.dimension) for model in models] == [
("qwen3-embedding-0.6b", 1024),
@@ -452,25 +447,24 @@ def test_1024_embedding_table_has_cosine_hnsw_index() -> None:
def test_embedding_model_aliases_normalize_to_provider_names(mocker: MockerFixture) -> None:
mocker.patch.dict(environ, {}, clear=False)
environ.pop("EBOOK_SEARCH_EMBEDDING_MODEL", None)
assert load_config().embedding_model == "qwen3-embedding-0.6b"
assert normalize_embedding_model() == "qwen3-embedding-0.6b"
environ["EBOOK_SEARCH_EMBEDDING_MODEL"] = "qwen3-embedding-0.6b"
assert load_config().embedding_model == "qwen3-embedding-0.6b"
assert normalize_embedding_model() == "qwen3-embedding-0.6b"
environ["EBOOK_SEARCH_EMBEDDING_MODEL"] = "Qwen3-Embedding-0.6B"
assert load_config().embedding_model == "qwen3-embedding-0.6b"
assert normalize_embedding_model() == "qwen3-embedding-0.6b"
environ["EBOOK_SEARCH_EMBEDDING_MODEL"] = "Qwen/Qwen3-Embedding-4B"
assert load_config().embedding_model == "qwen3-embedding-4b"
assert normalize_embedding_model() == "qwen3-embedding-4b"
environ["EBOOK_SEARCH_EMBEDDING_MODEL"] = "qwen3-embedding:8b"
assert load_config().embedding_model == "qwen3-embedding-8b"
assert normalize_embedding_model() == "qwen3-embedding-8b"
environ["EBOOK_SEARCH_EMBEDDING_MODEL"] = "qwen3-embedding-8b"
assert load_config().embedding_model == "qwen3-embedding-8b"
assert normalize_embedding_model() == "qwen3-embedding-8b"
def test_answer_generation_is_enabled_by_default(mocker: MockerFixture) -> None:
@@ -502,10 +496,10 @@ def test_chat_api_key_falls_back_to_ollama_api_key(mocker: MockerFixture) -> Non
assert config.vllm_api_key == "ollama-key"
async def test_answer_query_does_not_call_model_when_disabled(mocker: MockerFixture) -> None:
def test_answer_query_does_not_call_model_when_disabled() -> None:
config = load_config().model_copy(update={"answer_enabled": False})
result = SearchResult(chunk_id=1, text="source text", source_title="Book")
answer = await answer_query(mocker.Mock(), "question", [result], config)
answer = answer_query("question", [result], config)
assert "Answer generation is disabled" in answer
+9 -12
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import create_engine
from python.ebook_search.api.main import create_app
from python.ebook_search.config import EbookSearchConfig, RerankConfig
@@ -66,8 +66,8 @@ def test_is_confident_against_threshold() -> None:
def patch_app_runtime(mocker: MockerFixture):
mocker.patch(
"python.ebook_search.api.main.get_async_postgres_engine",
side_effect=lambda **_kwargs: create_async_engine("sqlite+aiosqlite:///:memory:"),
"python.ebook_search.api.main.get_postgres_engine",
side_effect=lambda **_kwargs: create_engine("sqlite+pysqlite:///:memory:", future=True),
)
mocker.patch("python.ebook_search.api.main.ensure_bm25_corpus", side_effect=lambda _session, _config: None)
@@ -75,12 +75,11 @@ def patch_app_runtime(mocker: MockerFixture):
def test_low_confidence_skips_answer_generation(mocker: MockerFixture) -> None:
called = False
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(query=query, rank_label="Hybrid", results=make_results(1, vector_score=0.05))
def fake_answer_query(_client, _query, _results, _config):
def fake_answer_query(_query, _results, _config):
nonlocal called
called = True
return "answer"
@@ -105,15 +104,14 @@ def test_low_confidence_skips_answer_generation(mocker: MockerFixture) -> None:
def test_invalid_citation_is_flagged(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(query=query, rank_label="Hybrid", results=make_results(2, vector_score=0.9))
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "Per the text [9].",
side_effect=lambda _query, _results, _config: "Per the text [9].",
)
patch_app_runtime(mocker)
app = create_app()
@@ -128,15 +126,14 @@ def test_invalid_citation_is_flagged(mocker: MockerFixture) -> None:
def test_grounded_answer_has_no_warning_badge(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(query=query, rank_label="Hybrid", results=make_results(2, vector_score=0.9))
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "Grounded in [1] and [2].",
side_effect=lambda _query, _results, _config: "Grounded in [1] and [2].",
)
patch_app_runtime(mocker)
app = create_app()
+5 -5
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import create_engine
from python.ebook_search.api.main import create_app
from python.ebook_search.config import EbookSearchConfig, RerankConfig
@@ -18,18 +18,18 @@ if TYPE_CHECKING:
def fake_get_postgres_engine(**_kwargs):
"""Return an in-memory engine for route tests."""
return create_async_engine("sqlite+aiosqlite:///:memory:")
return create_engine("sqlite+pysqlite:///:memory:", future=True)
def patch_app_runtime(mocker: MockerFixture):
mocker.patch("python.ebook_search.api.main.get_async_postgres_engine", side_effect=fake_get_postgres_engine)
mocker.patch("python.ebook_search.api.main.get_postgres_engine", side_effect=fake_get_postgres_engine)
mocker.patch("python.ebook_search.api.main.ensure_bm25_corpus", side_effect=lambda _session, _config: None)
def patch_dependencies(mocker: MockerFixture, *, database=True, embedding=True, chat=True, bm25="ok"):
mocker.patch(f"{HEALTH_MODULE}.check_database", side_effect=lambda _session: database)
mocker.patch(f"{HEALTH_MODULE}.check_embedding_endpoint", side_effect=lambda _client, _config: embedding)
mocker.patch(f"{HEALTH_MODULE}.check_chat_endpoint", side_effect=lambda _client, _config: chat)
mocker.patch(f"{HEALTH_MODULE}.check_embedding_endpoint", side_effect=lambda _config: embedding)
mocker.patch(f"{HEALTH_MODULE}.check_chat_endpoint", side_effect=lambda _config: chat)
mocker.patch(f"{HEALTH_MODULE}.check_bm25_status", side_effect=lambda _config: bm25)
+9 -56
View File
@@ -10,59 +10,13 @@ import pytest
from python.ebook_search.answer import answer_query
from python.ebook_search.config import EbookSearchConfig, RerankConfig
from python.ebook_search.embeddings import embed_texts
from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint
from python.ebook_search.search import SearchResult
if TYPE_CHECKING:
from pytest_mock import MockerFixture
def make_async_client(mocker: MockerFixture, fake_post) -> httpx.AsyncClient:
"""Build a mock async client whose post call is served by fake_post."""
client = mocker.MagicMock(spec=httpx.AsyncClient)
client.post = mocker.AsyncMock(side_effect=fake_post)
return client
async def test_model_endpoint_checks_share_http_probe(mocker: MockerFixture) -> None:
client = mocker.MagicMock(spec=httpx.AsyncClient)
response = mocker.MagicMock(spec=httpx.Response)
client.get = mocker.AsyncMock(return_value=response)
config = EbookSearchConfig(
rerank=RerankConfig(enabled=False),
embedding_base_url="https://embedding.example/v1/",
vllm_base_url="https://chat.example/v1/",
vllm_api_key="secret",
)
assert await check_embedding_endpoint(client, config, timeout_seconds=2.0)
assert await check_chat_endpoint(client, config, timeout_seconds=3.0)
assert client.get.await_args_list == [
mocker.call("https://embedding.example/v1/models", headers={}, timeout=2.0),
mocker.call(
"https://chat.example/v1/models",
headers={"Authorization": "Bearer secret"},
timeout=3.0,
),
]
assert response.raise_for_status.call_count == 2
async def test_model_endpoint_checks_report_http_failures(mocker: MockerFixture) -> None:
client = mocker.MagicMock(spec=httpx.AsyncClient)
client.get = mocker.AsyncMock(
side_effect=[
httpx.ConnectError("embedding offline"),
httpx.ConnectError("chat offline"),
]
)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
assert not await check_embedding_endpoint(client, config)
assert not await check_chat_endpoint(client, config)
async def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -> None:
def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -> None:
captured: dict[str, object] = {}
def fake_post(url: str, **kwargs: object) -> httpx.Response:
@@ -74,7 +28,7 @@ async def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -
request=httpx.Request("POST", url),
)
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
config = EbookSearchConfig(
rerank=RerankConfig(enabled=False),
vllm_base_url="https://ollama.com/v1",
@@ -82,8 +36,7 @@ async def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -
chat_model="deepseek-v4-flash",
)
results = [SearchResult(chunk_id=1, text="source", source_title="Book")]
answer = await answer_query(client, "question", results, config)
answer = answer_query("question", [SearchResult(chunk_id=1, text="source", source_title="Book")], config)
assert answer == "grounded answer"
assert captured["url"] == "https://ollama.com/v1/chat/completions"
@@ -95,7 +48,7 @@ async def test_answer_query_uses_httpx_chat_completions(mocker: MockerFixture) -
assert payload["model"] == "deepseek-v4-flash"
async def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None:
def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None:
captured: dict[str, object] = {}
vector = [0.0] * 1024
@@ -108,14 +61,14 @@ async def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None:
request=httpx.Request("POST", url),
)
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
config = EbookSearchConfig(
rerank=RerankConfig(enabled=False),
embedding_base_url="http://bob:8000/v1",
embedding_model="qwen3-embedding-0.6b",
)
embeddings = await embed_texts(client, ["hello"], config)
embeddings = embed_texts(["hello"], config)
assert embeddings == [vector]
assert captured["url"] == "http://bob:8000/v1/embeddings"
@@ -125,12 +78,12 @@ async def test_embed_texts_uses_httpx_embeddings(mocker: MockerFixture) -> None:
assert kwargs["json"] == {"model": "qwen3-embedding-0.6b", "input": ["hello"]}
async def test_embed_texts_rejects_bad_response_shape(mocker: MockerFixture) -> None:
def test_embed_texts_rejects_bad_response_shape(mocker: MockerFixture) -> None:
def fake_post(url: str, **_kwargs: object) -> httpx.Response:
return httpx.Response(200, json={"data": [{}]}, request=httpx.Request("POST", url))
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
with pytest.raises(RuntimeError, match="Embedding request failed"):
await embed_texts(client, ["hello"], config)
embed_texts(["hello"], config)
File diff suppressed because it is too large Load Diff
+6 -90
View File
@@ -2,11 +2,10 @@
from __future__ import annotations
import asyncio
from threading import Event
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import create_engine
from python.ebook_search.config import EbookSearchConfig, RerankConfig
from python.ebook_search.search import SearchResult, search_ebooks
@@ -15,18 +14,18 @@ if TYPE_CHECKING:
from pytest_mock import MockerFixture
async def test_search_ebooks_runs_vector_and_bm25_in_parallel(mocker: MockerFixture) -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
def test_search_ebooks_runs_vector_and_bm25_in_parallel(mocker: MockerFixture) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
vector_started = Event()
bm25_started = Event()
received_engines: list[object] = []
async def fake_vector_candidates(received_engine, _client, query, _config):
def fake_vector_candidates(received_engine, query, _config):
"""Return vector candidates after confirming BM25 has started."""
received_engines.append(received_engine)
assert query == "what is parallel"
vector_started.set()
assert await asyncio.to_thread(bm25_started.wait, 2)
assert bm25_started.wait(timeout=2)
return [SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)]
def fake_bm25_candidates(query, _config):
@@ -40,9 +39,7 @@ async def test_search_ebooks_runs_vector_and_bm25_in_parallel(mocker: MockerFixt
mocker.patch("python.ebook_search.search.bm25_candidates", side_effect=fake_bm25_candidates)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=False
)
response = search_ebooks(engine, "what is parallel", config)
timings = {step.name: step for step in response.timings}
assert [result.chunk_id for result in response.results] == [1, 2]
@@ -50,84 +47,3 @@ async def test_search_ebooks_runs_vector_and_bm25_in_parallel(mocker: MockerFixt
assert timings["BM25 search"].counts_toward_total is False
assert timings["Hybrid retrieval"].counts_toward_total is True
assert received_engines == [engine]
async def test_search_ebooks_runs_phrase_detection_in_parallel_with_retrieval(mocker: MockerFixture) -> None:
"""Phrase detection joins the retrieval gather instead of running before it."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
phrase_started = Event()
async def fake_vector_candidates(_engine, _client, _query, _config):
"""Return vector candidates only once phrase detection has started."""
assert await asyncio.to_thread(phrase_started.wait, 2)
return [SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)]
async def fake_query_phrase_matches(_engine, _query, _config, *, phrase_matching):
"""Record that phrase detection started and return no matches."""
assert phrase_matching is True
phrase_started.set()
return []
mocker.patch("python.ebook_search.search.vector_candidates", side_effect=fake_vector_candidates)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
mocker.patch("python.ebook_search.search.query_phrase_matches", side_effect=fake_query_phrase_matches)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=True
)
timings = {step.name: step for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert timings["Protected phrase detection"].counts_toward_total is False
assert timings["Hybrid retrieval"].counts_toward_total is True
async def test_search_ebooks_skips_phrase_matching_when_disabled(mocker: MockerFixture) -> None:
"""Phrase matching can be disabled for one search request."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
mocker.patch(
"python.ebook_search.search.vector_candidates",
return_value=[SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)],
)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
detect_mock = mocker.patch("python.ebook_search.search.detect_protected_phrases_for_query")
boost_mock = mocker.patch("python.ebook_search.search.apply_phrase_mention_boosts")
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=False
)
timing_names = {step.name for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert response.phrase_matches == ()
assert "Protected phrase detection skipped" in timing_names
assert "Phrase mention boost skipped" in timing_names
detect_mock.assert_not_called()
boost_mock.assert_not_called()
async def test_search_ebooks_ignores_phrase_matching_when_config_disabled(mocker: MockerFixture) -> None:
"""The config kill switch overrides a request that asks for phrase matching."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
mocker.patch(
"python.ebook_search.search.vector_candidates",
return_value=[SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)],
)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
detect_mock = mocker.patch("python.ebook_search.search.detect_protected_phrases_for_query")
boost_mock = mocker.patch("python.ebook_search.search.apply_phrase_mention_boosts")
config = EbookSearchConfig(rerank=RerankConfig(enabled=False), phrase_matching_enabled=False)
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=True
)
timing_names = {step.name for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert response.phrase_matches == ()
assert "Protected phrase detection skipped" in timing_names
assert "Phrase mention boost skipped" in timing_names
detect_mock.assert_not_called()
boost_mock.assert_not_called()
+14 -22
View File
@@ -24,13 +24,6 @@ def candidates() -> list[SearchResult]:
]
def make_async_client(mocker: MockerFixture, fake_post) -> httpx.AsyncClient:
"""Build a mock async client whose post call is served by fake_post."""
client = mocker.MagicMock(spec=httpx.AsyncClient)
client.post = mocker.AsyncMock(side_effect=fake_post)
return client
def rerank_response(payload: dict[str, object] | None = None, *, content: bytes | None = None) -> httpx.Response:
return httpx.Response(
200,
@@ -66,7 +59,7 @@ def test_reranking_disabled_returns_original_fused_order() -> None:
assert [result.chunk_id for result in response.results] == [1, 2]
async def test_reranking_enabled_reorders_candidates(mocker: MockerFixture) -> None:
def test_reranking_enabled_reorders_candidates(mocker: MockerFixture) -> None:
def fake_post(_url: str, *, json: dict[str, object], timeout: float) -> httpx.Response:
assert timeout == 30
assert json == {
@@ -84,16 +77,16 @@ async def test_reranking_enabled_reorders_candidates(mocker: MockerFixture) -> N
}
)
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
results = await rerank_chunks(client, "query", candidates(), RerankConfig())
results = rerank_chunks("query", candidates(), RerankConfig())
assert [result.chunk_id for result in results] == [2, 1, 3]
assert [round(result.score, 3) for result in results] == [0.78, 0.37, 0.28]
assert [result.rerank_score for result in results] == [0.9, 0.1, 0.4]
async def test_reranking_cannot_ignore_hybrid_score(mocker: MockerFixture) -> None:
def test_reranking_cannot_ignore_hybrid_score(mocker: MockerFixture) -> None:
candidates = [
SearchResult(chunk_id=1, text="strong hybrid", source_title="A", score=1.0),
SearchResult(chunk_id=2, text="weak hybrid", source_title="B", score=0.1),
@@ -109,9 +102,9 @@ async def test_reranking_cannot_ignore_hybrid_score(mocker: MockerFixture) -> No
}
)
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
results = await rerank_chunks(client, "query", candidates, RerankConfig())
results = rerank_chunks("query", candidates, RerankConfig())
assert [result.chunk_id for result in results] == [1, 2]
assert results[0].score == pytest.approx(0.79)
@@ -119,9 +112,8 @@ async def test_reranking_cannot_ignore_hybrid_score(mocker: MockerFixture) -> No
assert results[1].rerank_score == 1.0
async def test_vllm_rerank_timeout_raises(mocker: MockerFixture) -> None:
def test_vllm_rerank_timeout_raises(mocker: MockerFixture) -> None:
def fake_rerank_chunks(
_client: httpx.AsyncClient,
_query: str,
_candidates: list[SearchResult],
_config: RerankConfig,
@@ -133,21 +125,21 @@ async def test_vllm_rerank_timeout_raises(mocker: MockerFixture) -> None:
config = EbookSearchConfig(rerank=RerankConfig(enabled=True), top_k=2)
with pytest.raises(httpx.TimeoutException, match="timeout"):
await apply_rerank(mocker.Mock(), "query", candidates(), config)
apply_rerank("query", candidates(), config)
async def test_malformed_vllm_rerank_json_does_not_crash_search(mocker: MockerFixture) -> None:
def test_malformed_vllm_rerank_json_does_not_crash_search(mocker: MockerFixture) -> None:
def fake_post(_url: str, **_kwargs: object) -> httpx.Response:
return rerank_response(content=b"not-json")
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
results = await rerank_chunks(client, "query", candidates()[:1], RerankConfig())
results = rerank_chunks("query", candidates()[:1], RerankConfig())
assert results[0].score == 0.3
async def test_vllm_rerank_scores_are_clamped(mocker: MockerFixture) -> None:
def test_vllm_rerank_scores_are_clamped(mocker: MockerFixture) -> None:
def fake_post(_url: str, **_kwargs: object) -> httpx.Response:
return rerank_response(
{
@@ -158,8 +150,8 @@ async def test_vllm_rerank_scores_are_clamped(mocker: MockerFixture) -> None:
}
)
client = make_async_client(mocker, fake_post)
mocker.patch.object(httpx, "post", side_effect=fake_post)
results = await rerank_chunks(client, "query", candidates()[:2], RerankConfig())
results = rerank_chunks("query", candidates()[:2], RerankConfig())
assert {result.chunk_id: result.rerank_score for result in results} == {1: 0.0, 2: 1.0}
+20 -343
View File
@@ -2,49 +2,32 @@
from __future__ import annotations
import asyncio
from compression import zstd
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from fastapi import BackgroundTasks
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlalchemy import create_engine
from python.ebook_search.api.bm25_tasks import refresh_bm25_for_engine
from python.ebook_search.api.judge_tasks import (
is_judging_book,
judge_book_phrases_for_app,
pop_book_judgment_outcome,
start_book_phrase_judgment,
)
from python.ebook_search.api.main import create_app
from python.ebook_search.config import EbookSearchConfig, RerankConfig
from python.ebook_search.embeddings import EmbeddingModelStats
from python.ebook_search.protected_phrases.models import (
CorpusPhraseStats,
PhraseCandidateGenerationResult,
PhraseJudgmentBackfillResult,
)
from python.ebook_search.search import SearchResponse, SearchResult
from python.ebook_search.timing import RuntimeStep
from python.orm.richie import EbookSource, RichieBase
if TYPE_CHECKING:
from pytest_mock import MockerFixture
from sqlalchemy.ext.asyncio import AsyncEngine
def patch_app_runtime(mocker: MockerFixture):
"""Patch app startup dependencies used by UI route tests."""
mocker.patch("python.ebook_search.api.main.get_async_postgres_engine", side_effect=fake_get_postgres_engine)
mocker.patch("python.ebook_search.api.main.get_postgres_engine", side_effect=fake_get_postgres_engine)
mocker.patch("python.ebook_search.api.main.ensure_bm25_corpus", side_effect=lambda _session, _config: None)
def fake_get_postgres_engine(**_kwargs):
"""Return an in-memory engine for route tests."""
return create_async_engine("sqlite+aiosqlite:///:memory:")
return create_engine("sqlite+pysqlite:///:memory:", future=True)
def test_search_page_uses_zstd_when_requested(mocker: MockerFixture) -> None:
@@ -60,68 +43,36 @@ def test_search_page_uses_zstd_when_requested(mocker: MockerFixture) -> None:
assert b"EPUB Search" in zstd.decompress(response.content)
def test_ui_form_passes_search_toggles_to_search_handler(mocker: MockerFixture) -> None:
def test_ui_form_passes_rerank_flag_to_search_handler(mocker: MockerFixture) -> None:
captured: dict[str, object] = {}
def fake_search_ebooks(_engine, _client, query, config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, config, *, rerank=False):
captured["query"] = query
captured["rerank"] = rerank
captured["phrase_matching"] = phrase_matching
captured["config"] = config
return SearchResponse(query=query, results=[], rank_label="Hybrid + rerank")
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "answer",
side_effect=lambda _query, _results, _config: "answer",
)
patch_app_runtime(mocker)
app = create_app()
app.state.config = EbookSearchConfig(rerank=RerankConfig(enabled=False), top_k=12, answer_enabled=True)
with TestClient(app) as client:
response = client.post(
"/search",
data={"query": "where is the quote?", "rerank": "true", "phrase_matching": "true"},
)
response = client.post("/search", data={"query": "where is the quote?", "rerank": "true"})
assert response.status_code == 200
assert "Hybrid + rerank" in response.text
assert captured["query"] == "where is the quote?"
assert captured["rerank"] is True
assert captured["phrase_matching"] is True
def test_ui_form_can_disable_phrase_matching(mocker: MockerFixture) -> None:
captured: dict[str, object] = {}
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
del rerank
captured["query"] = query
captured["phrase_matching"] = phrase_matching
return SearchResponse(query=query, results=[], rank_label="Hybrid")
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "answer",
)
patch_app_runtime(mocker)
app = create_app()
app.state.config = EbookSearchConfig(rerank=RerankConfig(enabled=False), top_k=12, answer_enabled=True)
with TestClient(app) as client:
response = client.post("/search", data={"query": "where is the quote?"})
assert response.status_code == 200
assert captured["query"] == "where is the quote?"
assert captured["phrase_matching"] is False
def test_ui_search_failure_returns_visible_error(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, _query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, _query, _config, *, rerank=False):
del rerank
del phrase_matching
msg = "search exploded"
raise RuntimeError(msg)
@@ -138,12 +89,11 @@ def test_ui_search_failure_returns_visible_error(mocker: MockerFixture) -> None:
def test_ui_answer_failure_still_returns_sources(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(query=query, results=[], rank_label="Hybrid")
def fake_answer_query(_client, _query, _results, _config):
def fake_answer_query(_query, _results, _config):
msg = "answer exploded"
raise RuntimeError(msg)
@@ -163,12 +113,11 @@ def test_ui_answer_failure_still_returns_sources(mocker: MockerFixture) -> None:
def test_ui_skips_answer_when_disabled(mocker: MockerFixture) -> None:
called = False
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(query=query, results=[], rank_label="Hybrid")
def fake_answer_query(_client, _query, _results, _config):
def fake_answer_query(_query, _results, _config):
nonlocal called
called = True
return "answer"
@@ -189,9 +138,8 @@ def test_ui_skips_answer_when_disabled(mocker: MockerFixture) -> None:
def test_ui_shows_component_scores(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(
query=query,
rank_label="Hybrid + rerank",
@@ -212,7 +160,7 @@ def test_ui_shows_component_scores(mocker: MockerFixture) -> None:
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "answer",
side_effect=lambda _query, _results, _config: "answer",
)
patch_app_runtime(mocker)
app = create_app()
@@ -228,47 +176,9 @@ def test_ui_shows_component_scores(mocker: MockerFixture) -> None:
assert "RRF" in response.text
def test_ui_shows_matched_phrases_that_boosted_a_result(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
del rerank
del phrase_matching
return SearchResponse(
query=query,
rank_label="Hybrid",
results=[
SearchResult(
chunk_id=1,
text="source text",
source_title="Book",
score=0.9,
phrase_hit_count=3,
matched_phrases=("lock in", "haden's syndrome"),
)
],
)
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "answer",
)
patch_app_runtime(mocker)
app = create_app()
app.state.config = EbookSearchConfig(rerank=RerankConfig(enabled=False), answer_enabled=True)
with TestClient(app) as client:
response = client.post("/search", data={"query": "what is lock in?"})
assert response.status_code == 200
assert "boosted by" in response.text
assert "lock in" in response.text
assert "haden&#39;s syndrome" in response.text
def test_ui_shows_search_runtime_chart(mocker: MockerFixture) -> None:
def fake_search_ebooks(_engine, _client, query, _config, *, rerank=False, phrase_matching=False):
def fake_search_ebooks(_engine, query, _config, *, rerank=False):
del rerank
del phrase_matching
return SearchResponse(
query=query,
rank_label="Hybrid",
@@ -282,7 +192,7 @@ def test_ui_shows_search_runtime_chart(mocker: MockerFixture) -> None:
mocker.patch("python.ebook_search.api.routes.search.search_ebooks", side_effect=fake_search_ebooks)
mocker.patch(
"python.ebook_search.api.routes.search.answer_query",
side_effect=lambda _client, _query, _results, _config: "answer",
side_effect=lambda _query, _results, _config: "answer",
)
patch_app_runtime(mocker)
app = create_app()
@@ -304,7 +214,7 @@ def test_ui_embed_all_batches_until_complete(mocker: MockerFixture) -> None:
counts = iter([32, 32, 5, 0])
batch_sizes: list[int] = []
def fake_embed_missing_chunks(_session, _client, config):
def fake_embed_missing_chunks(_session, config):
batch_sizes.append(config.embedding_batch_size)
return next(counts)
@@ -346,7 +256,7 @@ def test_ui_scan_schedules_bm25_refresh_after_database_change(mocker: MockerFixt
assert scheduled is True
async def test_bm25_refresh_clears_loaded_corpus_cache(mocker: MockerFixture) -> None:
def test_bm25_refresh_clears_loaded_corpus_cache(mocker: MockerFixture) -> None:
refreshed: list[object] = []
cache_cleared = False
@@ -359,146 +269,16 @@ async def test_bm25_refresh_clears_loaded_corpus_cache(mocker: MockerFixture) ->
mocker.patch("python.ebook_search.api.bm25_tasks.refresh_bm25_corpus", side_effect=fake_refresh_bm25_corpus)
mocker.patch("python.ebook_search.api.bm25_tasks.load_bm25_corpus.cache_clear", side_effect=fake_cache_clear)
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
await refresh_bm25_for_engine(engine, config)
refresh_bm25_for_engine(engine, config)
assert len(refreshed) == 1
assert refreshed[0][1] == config
assert cache_cleared is True
def build_engine_with_book() -> AsyncEngine:
"""Create a shareable in-memory async engine holding one indexed book."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async def seed() -> None:
async with engine.begin() as connection:
await connection.run_sync(RichieBase.metadata.create_all)
async with AsyncSession(engine) as session:
session.add(
EbookSource(
title="Book",
author="Author",
language=None,
publisher=None,
identifier=None,
file_path="/library/book.epub",
file_sha256="a" * 64,
file_mtime=datetime.now(tz=UTC),
file_size=10,
)
)
await session.commit()
asyncio.run(seed())
return engine
def test_ui_judge_phrases_redirects_and_judges_in_background(mocker: MockerFixture) -> None:
mocker.patch("python.ebook_search.api.main.get_async_postgres_engine", return_value=build_engine_with_book())
mocker.patch("python.ebook_search.api.main.ensure_bm25_corpus", side_effect=lambda _session, _config: None)
judged_source_ids: list[list[int]] = []
def fake_judge(_engine: object, _config: object, *, source_ids: list[int]) -> PhraseJudgmentBackfillResult:
judged_source_ids.append(source_ids)
return PhraseJudgmentBackfillResult(
books_seen=1,
books_judged=1,
books_failed=0,
candidates_judged=3,
protected_phrases=2,
phrase_mentions=4,
)
mocker.patch(
"python.ebook_search.api.judge_tasks.judge_candidate_phrases_for_books",
side_effect=fake_judge,
)
app = create_app()
with TestClient(app) as client:
response = client.post("/books/1/judge-phrases", follow_redirects=False)
detail_after = client.get("/books/1")
detail_again = client.get("/books/1")
assert response.status_code == 303
assert response.headers["location"] == "/books/1"
assert judged_source_ids == [[1]]
assert "Judged 3 candidates; 2 protected phrases promoted" in detail_after.text
assert "Judged 3 candidates" not in detail_again.text
def test_ui_book_detail_shows_judging_in_progress(mocker: MockerFixture) -> None:
mocker.patch("python.ebook_search.api.main.get_async_postgres_engine", return_value=build_engine_with_book())
mocker.patch("python.ebook_search.api.main.ensure_bm25_corpus", side_effect=lambda _session, _config: None)
mocker.patch("python.ebook_search.api.routes.page.is_judging_book", return_value=True)
app = create_app()
with TestClient(app) as client:
response = client.get("/books/1")
assert response.status_code == 200
assert "Judging candidate phrases in the background" in response.text
assert "disabled" in response.text
def test_book_phrase_judgment_rejects_duplicate_while_queued(mocker: MockerFixture) -> None:
mocker.patch(
"python.ebook_search.api.judge_tasks.judge_candidate_phrases_for_books",
return_value=PhraseJudgmentBackfillResult(
books_seen=1,
books_judged=1,
books_failed=0,
candidates_judged=3,
protected_phrases=2,
phrase_mentions=4,
),
)
app = create_app()
app.state.engine = None
app.state.config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
background_tasks = BackgroundTasks()
assert start_book_phrase_judgment(app, background_tasks, 1) is True
assert is_judging_book(app, 1) is True
assert start_book_phrase_judgment(app, background_tasks, 1) is False
assert len(background_tasks.tasks) == 1
asyncio.run(judge_book_phrases_for_app(app, 1))
assert is_judging_book(app, 1) is False
assert pop_book_judgment_outcome(app, 1) == "Judged 3 candidates; 2 protected phrases promoted"
assert pop_book_judgment_outcome(app, 1) is None
assert start_book_phrase_judgment(app, background_tasks, 1) is True
def test_book_phrase_judgment_records_failure_outcome(mocker: MockerFixture) -> None:
def fake_judge(_engine: object, _config: object, *, source_ids: list[int]) -> PhraseJudgmentBackfillResult:
del source_ids
message = "llm judge unavailable"
raise RuntimeError(message)
mocker.patch(
"python.ebook_search.api.judge_tasks.judge_candidate_phrases_for_books",
side_effect=fake_judge,
)
app = create_app()
app.state.engine = None
app.state.config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
start_book_phrase_judgment(app, BackgroundTasks(), 7)
asyncio.run(judge_book_phrases_for_app(app, 7))
assert is_judging_book(app, 7) is False
assert pop_book_judgment_outcome(app, 7) == "Judging failed; see server logs for details"
def test_admin_page_shows_embedding_counts_by_model(mocker: MockerFixture) -> None:
def fake_embedding_model_stats(_session):
return [
@@ -517,10 +297,6 @@ def test_admin_page_shows_embedding_counts_by_model(mocker: MockerFixture) -> No
]
mocker.patch("python.ebook_search.api.routes.admin.embedding_model_stats", side_effect=fake_embedding_model_stats)
mocker.patch(
"python.ebook_search.api.routes.admin.corpus_phrase_stats",
return_value=fake_corpus_phrase_stats(),
)
patch_app_runtime(mocker)
app = create_app()
@@ -534,102 +310,3 @@ def test_admin_page_shows_embedding_counts_by_model(mocker: MockerFixture) -> No
assert "24" in response.text
assert "qwen3-embedding-4b" in response.text
assert "2560" in response.text
def fake_corpus_phrase_stats() -> CorpusPhraseStats:
"""Build distinctive corpus phrase stats for admin page assertions."""
return CorpusPhraseStats(
total_books=17,
books_with_candidates=13,
books_fully_judged=11,
candidate_phrases=901,
judged_candidates=703,
unjudged_candidates=198,
protected_phrases=157,
)
def test_admin_page_shows_protected_phrase_stats(mocker: MockerFixture) -> None:
mocker.patch("python.ebook_search.api.routes.admin.embedding_model_stats", return_value=[])
mocker.patch(
"python.ebook_search.api.routes.admin.corpus_phrase_stats",
return_value=fake_corpus_phrase_stats(),
)
patch_app_runtime(mocker)
app = create_app()
with TestClient(app) as client:
response = client.get("/admin")
assert response.status_code == 200
assert "Protected phrases" in response.text
for value in ("17", "13", "11", "901", "703", "198", "157"):
assert value in response.text
def test_ui_regenerate_all_phrases_generates_every_book(mocker: MockerFixture) -> None:
def fake_generate(_session, _config):
return PhraseCandidateGenerationResult(books_seen=5, books_built=5, candidate_phrases=99)
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-all")
assert response.status_code == 200
assert "5 of 5 books" in response.text
def test_ui_judge_missing_phrases_judges_only_pending_books(mocker: MockerFixture) -> None:
captured: dict[str, object] = {}
async def fake_judge(_engine, _config, *, source_ids=None):
captured["source_ids"] = source_ids
return PhraseJudgmentBackfillResult(
books_seen=2,
books_judged=2,
books_failed=0,
candidates_judged=10,
protected_phrases=4,
phrase_mentions=9,
)
mocker.patch(
"python.ebook_search.api.routes.admin.judge_candidate_phrases_for_books",
side_effect=fake_judge,
)
mocker.patch(
"python.ebook_search.api.routes.admin.book_ids_pending_first_judgment",
return_value=[3, 5],
)
patch_app_runtime(mocker)
app = create_app()
with TestClient(app) as client:
response = client.post("/admin/phrases/judge-missing")
assert response.status_code == 200
assert captured["source_ids"] == [3, 5]
assert "4 protected phrases" in response.text
def test_ui_judge_missing_phrases_reports_when_nothing_is_pending(mocker: MockerFixture) -> None:
judge = mocker.patch("python.ebook_search.api.routes.admin.judge_candidate_phrases_for_books")
mocker.patch(
"python.ebook_search.api.routes.admin.book_ids_pending_first_judgment",
return_value=[],
)
patch_app_runtime(mocker)
app = create_app()
with TestClient(app) as client:
response = client.post("/admin/phrases/judge-missing")
assert response.status_code == 200
assert "have been judged" in response.text
judge.assert_not_called()
@@ -76,7 +76,6 @@
"cSpell.userWords": [
"Cahill",
"Corvidae",
"dedup",
"drivername",
"fastapi",
"Michal",