added answer.py and config
This commit is contained in:
@@ -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."
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user