Convert the ebook-search web app to async end to end and add concurrency to the protected-phrase extraction and judging pipeline so large books no longer block the event loop or the UI. ORM / infra: - Add get_async_postgres_engine and factor shared URL/connect_args building into build_postgres_url (reused by the sync and async engine builders) - Add async FastAPI session helpers (get_async_db, AsyncDbSession) with expire_on_commit=False to avoid implicit IO under asyncio App: - Use AsyncEngine/AsyncSession throughout routes, search, ingest, embeddings, answer, rerank and LLM calls; convert handlers to async - Share a single httpx.AsyncClient in app state for LLM requests; size the connection pool for concurrent phrase-judging workers - Add judge_tasks: run per-book judging as tracked background tasks so a book already being judged isn't double-queued Protected phrases: - Add a process pool (pool.py) and worker-count config (extraction/judge book/phrase workers) to parallelize candidate generation and judging - Split admin actions into all/missing variants for generation and judging Config: - Add protected_phrase_extraction_workers, phrase_judge_book_workers, phrase_judge_phrase_workers
132 lines
5.0 KiB
Python
132 lines
5.0 KiB
Python
"""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("ebook_book_phrase_judgment_already_running source_id=%s", 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("ebook_book_phrase_judgment_queued source_id=%s", 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(
|
|
"ebook_book_phrase_judgment_complete source_id=%s judged=%s protected=%s mentions=%s failed=%s",
|
|
source_id,
|
|
result.candidates_judged,
|
|
result.protected_phrases,
|
|
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("ebook_book_phrase_judgment_task_failed source_id=%s", 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)
|