chatbot-rag / src /answer_generator.py
scythe327's picture
Upload src/answer_generator.py with huggingface_hub
de3ebf5 verified
Raw
History Blame Contribute Delete
11.6 kB
import json
import re
import polars as pl
from src.config import Config
from src.llm import LocalLLM
from src.query_router import QueryRouter
from src.retriever import Retriever
class AnswerGenerator:
def __init__(
self,
config: Config,
llm: LocalLLM,
retriever: Retriever,
facts_df: pl.DataFrame,
pages_df: pl.DataFrame,
):
self.config = config
self.llm = llm
self.router = QueryRouter()
self.retriever = retriever
self.facts_df = facts_df
self.pages_df = pages_df
def answer(self, query: str) -> str:
route = self.router.route(query)
retrieved = self.retriever.retrieve(query)
numeric_results = None
if route in ("numeric", "calculation", "range", "comparison"):
numeric_results = self._search_facts(query)
if route == "numeric":
return self._answer_numeric(query, retrieved, numeric_results)
elif route == "calculation":
return self._answer_calculation(query, retrieved, numeric_results)
elif route == "comparison":
return self._answer_comparison(query, retrieved, numeric_results)
elif route == "range":
return self._answer_range(query, retrieved, numeric_results)
elif route == "policy":
return self._answer_policy(query, retrieved)
elif route == "summary":
return self._answer_summary(query, retrieved)
else:
return self._answer_general(query, retrieved)
def _search_facts(self, query: str) -> list[dict]:
if self.facts_df.is_empty():
return []
query_lower = query.lower()
numbers = re.findall(r"\d+", query)
query_numbers = [float(n) for n in numbers]
relevant_facts = []
for row in self.facts_df.to_dicts():
entity = row.get("entity", "").lower()
attribute = row.get("attribute", "").lower()
entity_words = entity.split()
if any(w in query_lower for w in entity_words if len(w) > 2):
relevant_facts.append(row)
continue
if attribute in query_lower:
relevant_facts.append(row)
continue
if query_numbers:
mn = row.get("min_quantity")
mx = row.get("max_quantity")
if mn is not None and mx is not None:
for qn in query_numbers:
if mn <= qn <= mx:
relevant_facts.append(row)
break
elif mn is not None and mx is None:
if any(qn >= mn for qn in query_numbers):
relevant_facts.append(row)
return relevant_facts
def _answer_numeric(self, query: str, retrieved: list[dict], facts: list[dict]) -> str:
if facts:
answer = self._build_numeric_answer(query, facts, retrieved)
if answer:
return answer
context = self._format_context(retrieved[:5])
return self._llm_answer(query, context)
def _answer_calculation(self, query: str, retrieved: list[dict], facts: list[dict]) -> str:
if facts:
calc_result = self._try_calculate(query, facts)
if calc_result:
return calc_result
context = self._format_context(retrieved[:5])
return self._llm_answer(
query,
context,
instruction="If the question requires calculation, perform the calculation step by step and show your reasoning.",
)
def _answer_comparison(self, query: str, retrieved: list[dict], facts: list[dict]) -> str:
if facts:
items = set(f["entity"] for f in facts if f.get("entity"))
if len(items) >= 2:
comparison = self._build_comparison(query, facts)
if comparison:
return comparison
context = self._format_context(retrieved[:7])
return self._llm_answer(query, context)
def _answer_range(self, query: str, retrieved: list[dict], facts: list[dict]) -> str:
if facts:
mn = min((f.get("min_quantity") or 0 for f in facts if f.get("min_quantity") is not None), default=None)
mx = max((f.get("max_quantity") or 0 for f in facts if f.get("max_quantity") is not None), default=None)
vals = sorted(set(f["value"] for f in facts if f.get("value")), key=float)
if mn is not None and mx is not None:
return (
f"The available range is {mn} to {mx}.\n"
+ f"Prices in this range: {', '.join(vals)}\n"
+ self._cite_sources(facts)
)
context = self._format_context(retrieved[:5])
return self._llm_answer(query, context)
def _answer_policy(self, query: str, retrieved: list[dict]) -> str:
context = self._format_context(retrieved[:7])
return self._llm_answer(
query,
context,
instruction="Answer based on policy/terms information. If the answer depends on missing information (e.g., user's specific situation), ask a follow-up question to clarify.",
)
def _answer_summary(self, query: str, retrieved: list[dict]) -> str:
context = self._format_context(retrieved[:7])
return self._llm_answer(query, context)
def _answer_general(self, query: str, retrieved: list[dict]) -> str:
if not retrieved:
return (
"I don't have enough information from the crawled data to answer that question. "
"Please try crawling a website first, or rephrase your question."
)
context = self._format_context(retrieved[:5])
return self._llm_answer(query, context)
def _try_calculate(self, query: str, facts: list[dict]) -> str | None:
numbers = re.findall(r"\d+[.,]?\d*", query)
if not numbers:
return None
query_lower = query.lower()
has_price_per = any("price" in f.get("attribute", "").lower() or "per" in f.get("attribute", "").lower() for f in facts)
has_unit_price = any(f.get("unit") == "per_item" for f in facts)
if has_price_per or has_unit_price:
for f in facts:
attr = f.get("attribute", "").lower()
if "price" in attr or "per" in attr:
try:
unit_price = float(f["value"])
except (ValueError, TypeError):
continue
qty = None
for n_str in numbers:
try:
candidate = float(n_str.replace(",", "."))
mn = f.get("min_quantity")
mx = f.get("max_quantity")
if mn is not None and mx is not None:
if mn <= candidate <= mx:
qty = candidate
break
else:
qty = candidate
break
except ValueError:
continue
if qty is not None:
total = qty * unit_price
currency = f.get("currency", "")
currency_symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(currency, "")
return (
f"**Calculation:** {qty} × {unit_price} = {total}\n\n"
+ f"For {qty} items at {currency_symbol}{unit_price} each, "
+ f"the total is **{currency_symbol}{total:.2f}**.\n\n"
+ self._cite_sources([f])
)
return None
def _build_numeric_answer(self, query: str, facts: list[dict], retrieved: list[dict]) -> str:
for f in facts:
attr = f.get("attribute", "").lower()
if "price" in attr:
value = f.get("value", "")
currency = f.get("currency", "USD")
unit = f.get("unit", "")
mn = f.get("min_quantity")
mx = f.get("max_quantity")
entity = f.get("entity", "")
parts = [f"The price for **{entity}** is **{value} {currency}**"]
if unit:
parts.append(f"({unit})")
if mn is not None and mx is not None:
if mn != mx:
parts.append(f"for quantities between {mn} and {mx}")
else:
parts.append(f"for quantity {mn}")
parts.append(f"\n\n{self._cite_sources([f])}")
return " ".join(parts)
return ""
def _build_comparison(self, query: str, facts: list[dict]) -> str:
by_entity: dict[str, list[dict]] = {}
for f in facts:
ent = f.get("entity", "unknown")
by_entity.setdefault(ent, []).append(f)
lines = []
for entity, efacts in by_entity.items():
prices = [f for f in efacts if "price" in f.get("attribute", "").lower()]
if prices:
vals = [f"{p['value']} {p.get('currency', '')}" for p in prices]
lines.append(f"- **{entity}**: {', '.join(vals)}")
if lines:
lines.append("")
lines.append(self._cite_sources(facts))
return "\n".join(lines)
return ""
def _llm_answer(self, query: str, context: str, instruction: str = "") -> str:
if not context:
return (
"I don't have enough information from the crawled data to answer that question. "
"Please try crawling a website first."
)
system_prompt = "You are a precise QA assistant that answers questions based ONLY on the provided crawled content."
if instruction:
system_prompt += f"\n\n{instruction}"
try:
text = self.llm.generate(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"},
],
max_tokens=self.config.llm_max_tokens,
temperature=self.config.llm_temperature,
)
if text:
return text
except Exception:
pass
return "I'm sorry, I couldn't generate an answer at this time. Please try again later."
def _format_context(self, retrieved: list[dict]) -> str:
if not retrieved:
return ""
parts = []
seen_urls = set()
for i, r in enumerate(retrieved, 1):
text = r.get("text", "")[:500]
url = r.get("url", "")
title = r.get("title", "")
source = f"[Source: {title}]({url})" if url else ""
if url:
seen_urls.add(url)
parts.append(f"[{i}] {text}\n{source}")
return "\n\n---\n\n".join(parts)
def _cite_sources(self, facts: list[dict]) -> str:
urls = set()
for f in facts:
url = f.get("source_url", "")
if url:
urls.add(url)
if urls:
return "**Sources:** " + ", ".join(sorted(urls))
return ""