From 258919c4618378c564b2c20c47a832624ee63bb2 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Fri, 12 Jun 2026 03:09:51 -0400 Subject: [PATCH] added answer.py and config --- python/ebook_search/answer.py | 57 +++++++++++++++++ python/ebook_search/config.py | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 python/ebook_search/answer.py create mode 100644 python/ebook_search/config.py diff --git a/python/ebook_search/answer.py b/python/ebook_search/answer.py new file mode 100644 index 0000000..4b85b21 --- /dev/null +++ b/python/ebook_search/answer.py @@ -0,0 +1,57 @@ +"""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: + from python.ebook_search.config import EbookSearchConfig + from python.ebook_search.search import SearchResult + +logger = logging.getLogger(__name__) + + +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") + 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 = request_chat_completion( + 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." diff --git a/python/ebook_search/config.py b/python/ebook_search/config.py new file mode 100644 index 0000000..8d21274 --- /dev/null +++ b/python/ebook_search/config.py @@ -0,0 +1,117 @@ +"""Configuration for the EPUB search app.""" + +from __future__ import annotations + +from dataclasses import dataclass +from os import getenv + + +def getenv_bool(name: str, *, default: bool) -> bool: + """Read a boolean environment variable with a default fallback.""" + value = getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def getenv_int(name: str, *, default: int) -> int: + """Read an integer environment variable with a default fallback.""" + value = getenv(name) + if value is None or not value.strip(): + return default + return int(value) + + +@dataclass(frozen=True) +class RerankConfig: + """vLLM reranker settings.""" + + enabled: bool = False + base_url: str = "http://192.168.90.25:8001" + model: str = "qwen3-reranker-06b" + candidates: int = 24 + timeout_seconds: float = 30.0 + + +@dataclass(frozen=True) +class EbookSearchConfig: + """Runtime settings for EPUB search.""" + + rerank: RerankConfig + top_k: int = 12 + library_paths: tuple[str, ...] = () + vllm_base_url: str = "https://ollama.com/v1" + vllm_api_key: str = "not-needed" + chat_model: str = "deepseek-v4-flash" + answer_enabled: bool = True + embedding_base_url: str = "http://192.168.90.25:8000/v1" + embedding_api_key: str = "not-needed" + embedding_model: str = "qwen3-embedding-0.6b" + embedding_batch_size: int = 32 + bm25_index_dir: str = ".ebook_search_bm25" + bm25_refresh_delay_seconds: int = 60 + + +def load_rerank_config() -> RerankConfig: + """Load reranker config from environment variables.""" + return RerankConfig( + enabled=getenv_bool("EBOOK_SEARCH_RERANK_ENABLED", default=False), + base_url=getenv("EBOOK_SEARCH_RERANK_BASE_URL", "http://192.168.90.25:8001"), + model=getenv("EBOOK_SEARCH_RERANK_MODEL", "qwen3-reranker-06b"), + candidates=getenv_int("EBOOK_SEARCH_RERANK_CANDIDATES", default=24), + timeout_seconds=float(getenv_int("EBOOK_SEARCH_RERANK_TIMEOUT_SECONDS", default=30)), + ) + + +def load_config() -> EbookSearchConfig: + """Load EPUB search config from environment variables.""" + return EbookSearchConfig( + rerank=load_rerank_config(), + top_k=getenv_int("EBOOK_SEARCH_TOP_K", default=12), + library_paths=library_paths_from_env(), + vllm_base_url=getenv("EBOOK_SEARCH_VLLM_BASE_URL", "https://ollama.com/v1"), + vllm_api_key=getenv("EBOOK_SEARCH_VLLM_API_KEY") or getenv("OLLAMA_API_KEY") or "not-needed", + chat_model=getenv("EBOOK_SEARCH_CHAT_MODEL", "deepseek-v4-flash"), + answer_enabled=getenv_bool("EBOOK_SEARCH_ANSWER_ENABLED", default=True), + embedding_base_url=getenv("EBOOK_SEARCH_EMBEDDING_BASE_URL", "http://192.168.90.25:8000/v1"), + embedding_api_key=getenv("EBOOK_SEARCH_EMBEDDING_API_KEY", "not-needed"), + embedding_model=normalize_embedding_model(), + embedding_batch_size=getenv_int("EBOOK_SEARCH_EMBEDDING_BATCH_SIZE", default=32), + bm25_index_dir=getenv("EBOOK_SEARCH_BM25_INDEX_DIR", ".ebook_search_bm25"), + bm25_refresh_delay_seconds=getenv_int("EBOOK_SEARCH_BM25_REFRESH_DELAY_SECONDS", default=60), + ) + + +def normalize_embedding_model(default: str = "qwen3-embedding-0.6b") -> str: + """Normalize supported embedding aliases to provider model names.""" + aliases = { + "Qwen3-Embedding-0.6B": "qwen3-embedding-0.6b", + "Qwen3-Embedding-4B": "qwen3-embedding-4b", + "Qwen3-Embedding-8B": "qwen3-embedding-8b", + "Qwen/Qwen3-Embedding-0.6B": "qwen3-embedding-0.6b", + "Qwen/Qwen3-Embedding-4B": "qwen3-embedding-4b", + "Qwen/Qwen3-Embedding-8B": "qwen3-embedding-8b", + "qwen3-embedding:0.6b": "qwen3-embedding-0.6b", + "qwen3-embedding:4b": "qwen3-embedding-4b", + "qwen3-embedding:8b": "qwen3-embedding-8b", + "qwen3-embedding-0.6b": "qwen3-embedding-0.6b", + "qwen3-embedding-4b": "qwen3-embedding-4b", + "qwen3-embedding-8b": "qwen3-embedding-8b", + } + + model = getenv("EBOOK_SEARCH_EMBEDDING_MODEL", default) + standard_model = aliases.get(model) + + if standard_model is None: + error = f"Embedding model {model} is not supported. Supported models are {aliases.keys()}" + raise ValueError(error) + + return standard_model + + +def library_paths_from_env() -> tuple[str, ...]: + """Read configured EPUB library paths from the environment.""" + value = getenv("EBOOK_SEARCH_LIBRARY_PATHS") + if value is None: + return () + return tuple(path for path in value.split(":") if path)