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
66 lines
1.9 KiB
Python
66 lines
1.9 KiB
Python
"""Grounded answer generation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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:
|
|
"""Answer a question using only retrieved chunks."""
|
|
if not config.answer_enabled:
|
|
logger.info("ebook_answer_skipped_disabled")
|
|
return "Answer generation is disabled. Source chunks are shown below."
|
|
|
|
if not results:
|
|
logger.info("ebook_answer_skipped_no_results")
|
|
return "No relevant sources were found."
|
|
|
|
logger.info(
|
|
"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,
|
|
config,
|
|
[
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Answer only from the provided context. Cite sources with bracketed numbers like [1]. "
|
|
"If the context is insufficient, say so."
|
|
),
|
|
},
|
|
{"role": "user", "content": f"Question:\n{query}\n\nContext:\n{context}"},
|
|
],
|
|
)
|
|
|
|
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."
|