7th_handle / src /app.py
Adoption's picture
feat
6bc6eba
Raw History Blame Contribute Delete
37.4 kB
import os
import pickle
import json
import logging
import re
import time
import urllib.error
import urllib.request
from functools import lru_cache
from typing import Any, Iterator, List
from dotenv import load_dotenv
from pinecone import Pinecone
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_groq import ChatGroq
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_pinecone import PineconeVectorStore
from langchain_community.retrievers import BM25Retriever
from langchain.chains import RetrievalQA
from langchain_core.prompts import PromptTemplate
from langchain_core.retrievers import BaseRetriever
from langchain_core.callbacks import CallbackManagerForRetrieverRun
load_dotenv()
# ===============================
# CONFIG
# ===============================
INDEX_NAME = "branham-index"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CHUNKS_FILE = os.path.join(BASE_DIR, "sermon_chunks.pkl")
GEMINI_EMBEDDING_MODEL = "models/gemini-embedding-001"
PINECONE_DIMENSION = 768
PRIORITY_K = 8
SERIES_DOCS_PER_SERMON = 2
BM25_K = 8
VECTOR_K = 8
MAX_CONTEXT_DOCS = 20
MIN_ENTITY_LENGTH = 4
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "groq").strip().lower()
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
logger = logging.getLogger(__name__)
UNSUPPORTED_ANSWER = "The retrieved sermon excerpts do not give enough support to answer that safely."
CONTEXT_POLICY = {
"Strict Mode": {
"max_docs": 8,
"selection": "ranked",
},
"Study Mode": {
"max_docs": 14,
"selection": "ranked",
},
"Summary Mode": {
"max_docs": 20,
"selection": "distributed",
},
}
# ===============================
# CANONICAL SERIES
# ===============================
SEVEN_SEALS_CANON = [
"63-0317M God Hiding Himself In Simplicity, Then Revealing Himself In The Same.pdf",
"63-0317E The Breach Between The Seven Church Ages And The Seven Seals.pdf",
"63-0318 The First Seal.pdf",
"63-0319 The Second Seal.pdf",
"63-0320 The Third Seal.pdf",
"63-0321 The Fourth Seal.pdf",
"63-0322 The Fifth Seal.pdf",
"63-0323 The Sixth Seal.pdf",
"63-0324M Questions And Answers On The Seals.pdf",
"63-0324E The Seventh Seal.pdf",
]
ADOPTION_CANON = [
"60-0515E Adoption #1.pdf",
"60-0518 Adoption #2.pdf",
"60-0522M Adoption #3.pdf",
"60-0522E Adoption #4.pdf",
]
DEMONOLOGY_CANON = [
"53-0608A Demonology, Physical Realm.pdf",
"53-0609A Demonology, Religious Realm.pdf",
"55-0724 Enticing Spirits.pdf",
]
SERIES_GROUPS = {
"seven seals": SEVEN_SEALS_CANON,
"adoption": ADOPTION_CANON,
"demonology": DEMONOLOGY_CANON,
}
SEAL_SERMONS = [
"63-0318 The First Seal.pdf",
"63-0319 The Second Seal.pdf",
"63-0320 The Third Seal.pdf",
"63-0321 The Fourth Seal.pdf",
"63-0322 The Fifth Seal.pdf",
"63-0323 The Sixth Seal.pdf",
"63-0324E The Seventh Seal.pdf",
]
SINGLE_SEAL_ALIASES = {
"first seal": "63-0318 The First Seal.pdf",
"1st seal": "63-0318 The First Seal.pdf",
"second seal": "63-0319 The Second Seal.pdf",
"2nd seal": "63-0319 The Second Seal.pdf",
"third seal": "63-0320 The Third Seal.pdf",
"3rd seal": "63-0320 The Third Seal.pdf",
"fourth seal": "63-0321 The Fourth Seal.pdf",
"4th seal": "63-0321 The Fourth Seal.pdf",
"fifth seal": "63-0322 The Fifth Seal.pdf",
"5th seal": "63-0322 The Fifth Seal.pdf",
"sixth seal": "63-0323 The Sixth Seal.pdf",
"6th seal": "63-0323 The Sixth Seal.pdf",
"seventh seal": "63-0324E The Seventh Seal.pdf",
"7th seal": "63-0324E The Seventh Seal.pdf",
}
SEVEN_SEALS_QUERY_HINTS = {
"63-0318": "first seal white horse bow crown conquer",
"63-0319": "second seal red horse sword take peace kill",
"63-0320": "third seal black horse balances wheat barley oil wine",
"63-0321": "fourth seal pale horse death hell eagle",
"63-0322": "fifth seal souls under the altar white robes Jews",
"63-0323": "sixth seal earthquake sun black moon blood stars fall",
"63-0324E": "seventh seal silence seven thunders coming of Christ end time",
}
SERIES_QUERY_HINTS = {
**SEVEN_SEALS_QUERY_HINTS,
"60-0515E": "adoption placing sons predestination inheritance position",
"60-0518": "adoption sons inheritance child placing position",
"60-0522M": "adoption holy spirit church position sons",
"60-0522E": "adoption manifestation sons inheritance placing",
"53-0608A": "demonology physical realm spirits sickness oppression demons",
"53-0609A": "demonology religious realm spirits worship deception demons",
"55-0724": "enticing spirits deception religious spirits influence",
}
ANSWER_MODE_INSTRUCTIONS = {
"Strict Mode": (
"Use strict grounding. If the retrieved excerpts do not directly support the answer, "
f"respond exactly: {UNSUPPORTED_ANSWER}"
),
"Study Mode": (
"Give a fuller study-style explanation, but keep every doctrinal claim tied to the "
"provided excerpts and source references."
),
"Summary Mode": (
"Prioritize concise summaries. If the user asks about a sermon, summarize only what the "
"retrieved excerpts from that sermon support."
),
}
# ===============================
# HELPERS
# ===============================
class GeminiEmbedding768(Embeddings):
"""Custom local HuggingFace BGE embedding client matched to the 768-dimension index."""
def __init__(self) -> None:
logger.info("Initializing local HuggingFace BGE embedding client (BAAI/bge-base-en-v1.5)...")
# Load HuggingFace BGE model locally on CPU (or CUDA/MPS if available, default to CPU for general reliability)
self.embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-base-en-v1.5",
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
logger.info("Local BGE embedding client initialized successfully!")
def embed_documents(self, texts: List[str]) -> List[List[float]]:
if not texts:
return []
return self.embeddings.embed_documents(texts)
def embed_query(self, text: str) -> List[float]:
return self.embeddings.embed_query(text)
def normalize(text: str) -> str:
return text.lower().replace("_", " ").replace("-", " ").strip()
@lru_cache(maxsize=1)
def load_chunks() -> List[Document]:
if not os.path.exists(CHUNKS_FILE):
return []
with open(CHUNKS_FILE, "rb") as f:
return pickle.load(f)
@lru_cache(maxsize=1)
def get_bm25_retriever():
bm25 = BM25Retriever.from_documents(load_chunks())
bm25.k = BM25_K
return bm25
@lru_cache(maxsize=1)
def get_search_bm25_retriever():
bm25 = BM25Retriever.from_documents(load_chunks())
bm25.k = 50
return bm25
@lru_cache(maxsize=1)
def get_vector_store():
return PineconeVectorStore(
index_name=INDEX_NAME,
embedding=GeminiEmbedding768(),
)
def extract_date_code(filename: str) -> str:
"""
Assumes filenames start with NN-NNNNE
Example: 62-0909E In His Presence.pdf
"""
return filename.split()[0].replace(".pdf", "")
def extract_query_sermon_code(query: str) -> str | None:
match = re.search(r"\b\d{2}-\d{4}[A-Z]?\b", query.upper())
return match.group(0) if match else None
def source_matches_code(source: str, code: str) -> bool:
return extract_date_code(source).upper() == code.upper()
def source_matches_title(source: str, title: str) -> bool:
return source_matches_code(source, extract_date_code(title).upper())
def source_matches_any_code(source: str, codes: set[str]) -> bool:
return extract_date_code(source).upper() in codes
def document_key(doc: Document) -> tuple[str, str, str]:
return (
doc.metadata.get("source", ""),
str(doc.metadata.get("paragraph", "")),
doc.page_content[:120],
)
def messagehub_link(filename: str) -> str:
code = extract_date_code(filename)
return f"https://www.messagehub.info/en/read.do?ref_num={code}"
STOPWORDS = {
"the", "a", "an", "of", "in", "on", "at", "and", "to", "for", "with", "by",
"what", "who", "where", "when", "why", "how", "did", "does", "do", "is",
"are", "was", "were", "be", "been", "being", "about", "teach", "taught",
"said", "say", "says", "explain", "explained", "brother", "branham",
"william", "sermon", "sermons", "message", "messages", "summarize",
"summary", "list", "state", "meaning", "events", "relationship",
}
GENERIC_TITLE_TOKENS = {
"know", "believe", "faith", "love", "life", "way", "truth", "word",
"god", "lord", "jesus", "christ", "christian", "church", "message",
}
def normalize_text(text: str) -> str:
text = text.lower()
text = re.sub(r"[^a-z0-9\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def extract_sermon_title(filename: str) -> str:
"""
'62-0909E In His Presence.pdf' → 'in his presence'
"""
name = filename.replace(".pdf", "").replace(".PDF", "")
parts = name.split(" ", 1)
if len(parts) == 2 and "-" in parts[0]:
name = parts[1]
return normalize_text(name)
def tokenize_meaningful(text: str) -> set:
return {
w for w in normalize_text(text).split()
if w not in STOPWORDS and len(w) > 2
}
def sermon_title_matches(user_query: str, filename: str) -> bool:
"""
Match only if ALL meaningful title words exist in user query.
Prevents partial matches like 'presence'.
"""
title_tokens = tokenize_meaningful(extract_sermon_title(filename))
query_tokens = tokenize_meaningful(user_query)
if not title_tokens:
return False
return title_tokens.issubset(query_tokens)
def explicit_title_lookup(query: str) -> bool:
query_clean = normalize_text(query)
if extract_query_sermon_code(query):
return True
title_lookup_phrases = {
"sermon called",
"message called",
"sermon titled",
"message titled",
"sermon named",
"message named",
"the sermon",
"the message",
"summarize",
"summary of",
}
return any(phrase in query_clean for phrase in title_lookup_phrases)
def safe_sermon_title_matches(user_query: str, filename: str) -> bool:
title_tokens = tokenize_meaningful(extract_sermon_title(filename))
if not title_tokens:
return False
if len(title_tokens) == 1 and next(iter(title_tokens)) in GENERIC_TITLE_TOKENS:
return False
if len(title_tokens) < 2 and not explicit_title_lookup(user_query):
return False
return sermon_title_matches(user_query, filename)
def detect_single_seal_title(query: str) -> str | None:
query_clean = normalize_text(query)
for alias, title in SINGLE_SEAL_ALIASES.items():
if alias in query_clean:
return title
return None
def query_entity_tokens(query: str) -> set[str]:
return {
token for token in tokenize_meaningful(query)
if len(token) >= MIN_ENTITY_LENGTH
}
def rank_by_query_terms(docs: List[Document], query: str) -> List[Document]:
terms = query_entity_tokens(query)
def score(doc: Document) -> tuple[int, int]:
text = normalize_text(doc.page_content)
hits = sum(1 for term in terms if term in text)
return hits, -len(text)
return sorted(docs, key=score, reverse=True)
def distributed_sample(docs: List[Document], limit: int) -> List[Document]:
if limit <= 0 or len(docs) <= limit:
return docs[:limit]
if limit == 1:
return [docs[0]]
step = (len(docs) - 1) / (limit - 1)
selected = []
used_indexes = set()
for i in range(limit):
index = round(i * step)
if index not in used_indexes:
selected.append(docs[index])
used_indexes.add(index)
return selected
def select_context_docs(docs: List[Document], query: str, answer_mode: str) -> List[Document]:
policy = CONTEXT_POLICY.get(answer_mode, CONTEXT_POLICY["Strict Mode"])
max_docs = policy["max_docs"]
if "seven seals" in normalize(query):
guaranteed_titles = SEVEN_SEALS_CANON if answer_mode == "Summary Mode" else SEAL_SERMONS
selected = []
seen = set()
for title in guaranteed_titles:
code = extract_date_code(title).upper()
for doc in docs:
if source_matches_code(doc.metadata.get("source", ""), code):
key = document_key(doc)
if key not in seen:
selected.append(doc)
seen.add(key)
break
remaining = max_docs - len(selected)
if remaining > 0:
for doc in docs:
key = document_key(doc)
if key not in seen:
selected.append(doc)
seen.add(key)
remaining -= 1
if remaining <= 0:
break
return selected[:max_docs]
if policy["selection"] == "distributed":
priority_docs = [d for d in docs if d.metadata.get("retrieval_origin") == "priority"]
other_docs = [d for d in docs if d.metadata.get("retrieval_origin") != "priority"]
selected = distributed_sample(priority_docs, min(len(priority_docs), max_docs))
remaining = max_docs - len(selected)
if remaining > 0:
selected.extend(rank_by_query_terms(other_docs, query)[:remaining])
return selected[:max_docs]
priority_docs = [d for d in docs if d.metadata.get("retrieval_origin") == "priority"]
other_docs = [d for d in docs if d.metadata.get("retrieval_origin") != "priority"]
selected = priority_docs[: min(len(priority_docs), max_docs)]
remaining = max_docs - len(selected)
if remaining > 0:
selected.extend(rank_by_query_terms(other_docs, query)[:remaining])
return selected[:max_docs]
def query_support_terms(query: str) -> set[str]:
terms = query_entity_tokens(query)
return {
term for term in terms
if term not in STOPWORDS and not re.fullmatch(r"\d+", term)
}
def evaluate_retrieval_confidence(
query: str,
docs: List[Document],
diagnostics: dict[str, Any],
) -> dict[str, Any]:
terms = query_support_terms(query)
context = normalize_text(" ".join(doc.page_content for doc in docs))
supported_terms = sorted(term for term in terms if term in context)
missing_terms = sorted(terms - set(supported_terms))
coverage = len(supported_terms) / len(terms) if terms else 1.0
confidence = "supported"
reason = "priority_or_supported_terms"
should_refuse = False
if not docs:
confidence = "insufficient"
reason = "no_documents"
should_refuse = True
elif diagnostics.get("priority_count", 0) > 0:
confidence = "supported"
reason = "priority_match"
elif terms and missing_terms:
confidence = "insufficient"
reason = "missing_query_terms_without_priority_match"
should_refuse = True
elif diagnostics.get("source_count", 0) < 2:
confidence = "partial"
reason = "too_few_documents"
return {
"retrieval_confidence": confidence,
"confidence_reason": reason,
"should_refuse_generation": should_refuse,
"query_terms": sorted(terms),
"supported_query_terms": supported_terms,
"missing_query_terms": missing_terms,
"query_term_coverage": round(coverage, 3),
}
# ===============================
# RETRIEVER
# ===============================
class BranhamRetriever(BaseRetriever):
"""
NotebookLM-style hybrid retriever:
- local priority
- semantic fallback
- series-aware
- safe + deduplicated
"""
def _get_relevant_documents(
self,
query: str,
*,
run_manager: CallbackManagerForRetrieverRun = None
) -> List[Document]:
docs, _ = retrieve_documents_with_diagnostics(query, answer_mode="Strict Mode")
return docs
def retrieve_documents_with_diagnostics(query: str, answer_mode: str = "Strict Mode"):
started_at = time.perf_counter()
query_clean = normalize(query)
chunks = load_chunks()
results: List[Document] = []
seen = set()
counts = {
"priority": 0,
"bm25": 0,
"pinecone": 0,
}
pinecone_status = "not_run"
pinecone_error = ""
def add_doc(doc: Document, origin: str) -> bool:
key = document_key(doc)
if key in seen:
return False
metadata = dict(doc.metadata)
metadata["retrieval_origin"] = origin
metadata["retrieval_rank"] = len(results) + 1
results.append(Document(page_content=doc.page_content, metadata=metadata))
seen.add(key)
counts[origin] = counts.get(origin, 0) + 1
return True
explicit_sermon = extract_query_sermon_code(query)
single_seal_title = detect_single_seal_title(query)
target_titles = []
for key, titles in SERIES_GROUPS.items():
if key in query_clean:
target_titles = titles
break
if explicit_sermon:
sermon_chunks = [
d for d in chunks
if source_matches_code(d.metadata.get("source", ""), explicit_sermon)
]
if sermon_chunks:
if answer_mode == "Summary Mode":
for d in distributed_sample(sermon_chunks, CONTEXT_POLICY["Summary Mode"]["max_docs"]):
add_doc(d, "priority")
else:
sermon_bm25 = BM25Retriever.from_documents(sermon_chunks)
sermon_bm25.k = PRIORITY_K
for d in sermon_bm25.invoke(query):
add_doc(d, "priority")
elif single_seal_title:
target_code = extract_date_code(single_seal_title).upper()
sermon_chunks = [
d for d in chunks
if source_matches_title(d.metadata.get("source", ""), single_seal_title)
]
if sermon_chunks:
seal_query = SERIES_QUERY_HINTS.get(target_code, query)
for d in rank_by_query_terms(sermon_chunks, seal_query)[:PRIORITY_K]:
add_doc(d, "priority")
elif target_titles:
if "seven seals" in query_clean and answer_mode != "Summary Mode":
titles_for_retrieval = SEAL_SERMONS
else:
titles_for_retrieval = target_titles
for title in titles_for_retrieval:
target_code = extract_date_code(title).upper()
sermon_chunks = [
d for d in chunks
if source_matches_code(d.metadata.get("source", ""), target_code)
]
if sermon_chunks:
series_query = SERIES_QUERY_HINTS.get(target_code, query)
for d in rank_by_query_terms(sermon_chunks, series_query)[:SERIES_DOCS_PER_SERMON]:
add_doc(d, "priority")
else:
title_chunks = [
d for d in chunks
if safe_sermon_title_matches(query, d.metadata.get("source", ""))
]
if title_chunks:
if answer_mode == "Summary Mode":
for d in distributed_sample(title_chunks, CONTEXT_POLICY["Summary Mode"]["max_docs"]):
add_doc(d, "priority")
else:
for d in rank_by_query_terms(title_chunks, query)[:PRIORITY_K]:
add_doc(d, "priority")
max_context_docs = CONTEXT_POLICY.get(answer_mode, CONTEXT_POLICY["Strict Mode"])["max_docs"]
if counts.get("priority", 0) < max_context_docs:
for d in get_bm25_retriever().invoke(query):
add_doc(d, "bm25")
pre_vector_docs = select_context_docs(results, query, answer_mode)
pre_vector_diagnostics = {
"source_count": len(pre_vector_docs),
"priority_count": sum(
1 for d in pre_vector_docs if d.metadata.get("retrieval_origin") == "priority"
),
}
pre_vector_confidence = evaluate_retrieval_confidence(
query,
pre_vector_docs,
pre_vector_diagnostics,
)
if counts.get("priority", 0) > 0:
pinecone_status = "skipped_priority_match"
elif pre_vector_confidence.get("should_refuse_generation"):
pinecone_status = "skipped_low_confidence"
else:
try:
vec_docs = get_vector_store().as_retriever(
search_kwargs={"k": VECTOR_K}
).invoke(query)
pinecone_status = "ok"
for d in vec_docs:
add_doc(d, "pinecone")
except Exception as exc:
pinecone_status = "failed"
pinecone_error = str(exc)
logger.warning("Pinecone vector search failed: %s", exc)
final_docs = select_context_docs(results, query, answer_mode)
distinct_sermons = {
d.metadata.get("source", "")
for d in final_docs
if d.metadata.get("source")
}
diagnostics = {
"retrieval_latency": round(time.perf_counter() - started_at, 3),
"source_count": len(final_docs),
"distinct_sermon_count": len(distinct_sermons),
"sermons": sorted(distinct_sermons),
"priority_count": sum(1 for d in final_docs if d.metadata.get("retrieval_origin") == "priority"),
"bm25_count": sum(1 for d in final_docs if d.metadata.get("retrieval_origin") == "bm25"),
"pinecone_count": sum(1 for d in final_docs if d.metadata.get("retrieval_origin") == "pinecone"),
"pinecone_status": pinecone_status,
"pinecone_error": pinecone_error,
"context_policy": answer_mode,
"candidate_count": len(results),
"max_context_docs": max_context_docs,
}
diagnostics.update(evaluate_retrieval_confidence(query, final_docs, diagnostics))
return final_docs, diagnostics
class _LegacyBranhamRetriever(BaseRetriever):
def _get_relevant_documents(
self,
query: str,
*,
run_manager: CallbackManagerForRetrieverRun = None
) -> List[Document]:
docs, _ = retrieve_documents_with_diagnostics(query, answer_mode="Strict Mode")
return docs
# ===============================
# PROMPT
# ===============================
PROMPT_TEMPLATE = """
You are a careful research assistant helping one person understand William Marrion Branham's sermons.
Do not roleplay, impersonate, or speak as William Marrion Branham.
Always refer to him in the third person as "Brother Branham" or "William Branham."
RULES:
- You are speaking to only one person
- Be faithful to the sermons provided.
- Do NOT invent doctrine.
- Answer only from the provided context. Do not use outside knowledge.
- If the context does not clearly support the answer, say: "The retrieved sermon excerpts do not give enough support to answer that safely."
- If the context is partial, say what is supported and what is not supported.
- Do not smooth over contradictions or gaps. State them plainly.
- Use a respectful explanatory tone, not a preaching or prophetic persona.
- Be structured and clear.
- Use headings and bullet points.
- Explain symbols plainly.
- Prefer paraphrase, but preserve meaning.
- Ignore tape noise or filler language.
- If a question asks for a sermon summary, summarize only that sermon.
- If the question references the Seven Seals, prioritize the 1963 series.
- Phrase claims as "Brother Branham said/taught/explained..." when the context supports them.
- Do not say "I testified," "my ministry," "my dear friend," or anything that makes the assistant sound like Brother Branham.
- If context uses first-person sermon language, convert it to third-person attribution.
- Include source references for important claims using this format: [source, Para paragraph].
- Do not cite a source unless it appears in the provided context.
- Keep direct quotes short. Prefer paraphrase with source references.
ANSWER MODE:
{answer_mode_instructions}
CONTEXT:
{context_str}
QUESTION:
{question}
ANSWER:
"""
CONVERSATIONAL_PROMPT_TEMPLATE = """
You are a careful research assistant helping one person understand William Marrion Branham's sermons.
Do not roleplay, impersonate, or speak as William Marrion Branham.
Always refer to him in the third person as "Brother Branham" or "William Branham."
RULES:
- Be faithful to the sermons provided.
- Use the conversation history only to understand what the user's latest question refers to.
- Answer only from the provided context. Do not use outside knowledge.
- If the context does not clearly support the answer, say: "The retrieved sermon excerpts do not give enough support to answer that safely."
- If the context is partial, say what is supported and what is not supported.
- Use a natural conversational tone, but do not preach or impersonate.
- Phrase claims as "Brother Branham said/taught/explained..." when the context supports them.
- Do not say "I testified," "my ministry," "my dear friend," or anything that makes the assistant sound like Brother Branham.
- Include source references for important claims using this format: [source, Para paragraph].
- Do not cite a source unless it appears in the provided context.
- Keep direct quotes short. Prefer paraphrase with source references.
ANSWER MODE:
{answer_mode_instructions}
CONVERSATION HISTORY:
{conversation_history}
CONTEXT:
{context_str}
LATEST QUESTION:
{question}
ANSWER:
"""
QUESTION_REWRITE_PROMPT_TEMPLATE = """
Rewrite the user's latest question as a standalone search query for retrieving William Branham sermon excerpts.
Use the conversation history only to resolve references like "it", "that", "he", "those", "the first one", or "explain more".
Do not answer the question.
Do not add doctrine or facts not present in the conversation.
Return only the standalone question.
CONVERSATION HISTORY:
{conversation_history}
LATEST QUESTION:
{question}
STANDALONE QUESTION:
"""
PROMPT = PromptTemplate(
template=PROMPT_TEMPLATE,
input_variables=["context_str", "question"],
partial_variables={
"answer_mode_instructions": ANSWER_MODE_INSTRUCTIONS["Strict Mode"],
},
)
DOCUMENT_PROMPT = PromptTemplate(
template=(
"SOURCE: {source}\n"
"PARAGRAPH: {paragraph}\n"
"TEXT:\n{page_content}"
),
input_variables=["page_content", "source", "paragraph"],
)
QUESTION_REWRITE_PROMPT = PromptTemplate(
template=QUESTION_REWRITE_PROMPT_TEMPLATE,
input_variables=["conversation_history", "question"],
)
# ===============================
# PUBLIC API
# ===============================
@lru_cache(maxsize=1)
def get_llm():
if LLM_PROVIDER == "groq":
if not os.getenv("GROQ_API_KEY"):
raise ValueError("GROQ_API_KEY is required when LLM_PROVIDER=groq.")
return ChatGroq(
model=GROQ_MODEL,
temperature=0.0,
)
if LLM_PROVIDER == "gemini":
if not os.getenv("GOOGLE_API_KEY"):
raise ValueError("GOOGLE_API_KEY is required when LLM_PROVIDER=gemini.")
return ChatGoogleGenerativeAI(
model=GEMINI_MODEL,
temperature=0.0,
convert_system_message_to_human=True,
)
raise ValueError(
f"Unsupported LLM_PROVIDER '{LLM_PROVIDER}'. Use 'groq' or 'gemini'."
)
def get_rag_chain():
llm = get_llm()
retriever = BranhamRetriever()
chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever,
chain_type="stuff",
return_source_documents=True,
chain_type_kwargs={
"prompt": PROMPT,
"document_prompt": DOCUMENT_PROMPT,
"document_variable_name": "context_str",
},
input_key="question",
)
return chain
def format_documents_for_context(docs: List[Document]) -> str:
return "\n\n".join(
DOCUMENT_PROMPT.format(
page_content=doc.page_content,
source=doc.metadata.get("source", ""),
paragraph=doc.metadata.get("paragraph", ""),
)
for doc in docs
)
def classify_grounding(answer: str, docs: List[Document]) -> str:
normalized = answer.lower()
if not docs or UNSUPPORTED_ANSWER.lower() in normalized:
return "insufficient"
if "not enough support" in normalized or "partial" in normalized or "not clearly supported" in normalized:
return "partial"
if len(docs) < 3:
return "partial"
return "supported"
def format_conversation_history(history: List[dict[str, str]], max_messages: int = 8) -> str:
rows = []
for item in history[-max_messages:]:
role = item.get("role", "").strip().lower()
content = " ".join(item.get("content", "").split())
if role not in {"user", "assistant"} or not content:
continue
if len(content) > 900:
content = content[:900].rstrip() + "..."
rows.append(f"{role.upper()}: {content}")
return "\n".join(rows) if rows else "No prior conversation."
def rewrite_conversational_question(question: str, history: List[dict[str, str]]) -> str:
if not history:
return question
prompt_text = QUESTION_REWRITE_PROMPT.format(
conversation_history=format_conversation_history(history),
question=question,
)
response = get_llm().invoke(prompt_text)
rewritten = getattr(response, "content", str(response)).strip()
return rewritten or question
def normalize_answer_mode(answer_mode: str) -> str:
if answer_mode not in ANSWER_MODE_INSTRUCTIONS:
return "Strict Mode"
return answer_mode
def build_answer_prompt(question: str, answer_mode: str, docs: List[Document]) -> str:
context_str = format_documents_for_context(docs)
return PROMPT_TEMPLATE.format(
context_str=context_str,
question=question,
answer_mode_instructions=ANSWER_MODE_INSTRUCTIONS[answer_mode],
)
def build_conversational_answer_prompt(
question: str,
retrieval_question: str,
answer_mode: str,
docs: List[Document],
history: List[dict[str, str]],
) -> str:
context_str = format_documents_for_context(docs)
latest_question = question
if retrieval_question != question:
latest_question = f"{question}\n\nStandalone retrieval question: {retrieval_question}"
return CONVERSATIONAL_PROMPT_TEMPLATE.format(
context_str=context_str,
conversation_history=format_conversation_history(history),
question=latest_question,
answer_mode_instructions=ANSWER_MODE_INSTRUCTIONS[answer_mode],
)
def add_generation_diagnostics(
diagnostics: dict[str, Any],
total_started_at: float,
answer_mode: str,
) -> dict[str, Any]:
diagnostics["total_latency"] = round(time.perf_counter() - total_started_at, 3)
diagnostics["answer_mode"] = answer_mode
diagnostics["llm_provider"] = LLM_PROVIDER
diagnostics["llm_model"] = GROQ_MODEL if LLM_PROVIDER == "groq" else GEMINI_MODEL
return diagnostics
def build_refusal_result(
diagnostics: dict[str, Any],
total_started_at: float,
answer_mode: str,
) -> dict[str, Any]:
diagnostics["retrieved_source_count"] = diagnostics.get("source_count", 0)
diagnostics["retrieved_distinct_sermon_count"] = diagnostics.get("distinct_sermon_count", 0)
diagnostics["source_count"] = 0
diagnostics["distinct_sermon_count"] = 0
diagnostics["sermons"] = []
add_generation_diagnostics(diagnostics, total_started_at, answer_mode)
return {
"answer": UNSUPPORTED_ANSWER,
"source_documents": [],
"grounding_status": "insufficient",
"diagnostics": diagnostics,
}
def prepare_answer_context(
question: str,
answer_mode: str = "Strict Mode",
history: List[dict[str, str]] | None = None,
conversational: bool = False,
) -> dict[str, Any]:
total_started_at = time.perf_counter()
answer_mode = normalize_answer_mode(answer_mode)
history = history or []
retrieval_question = (
rewrite_conversational_question(question, history)
if conversational and history
else question
)
docs, diagnostics = retrieve_documents_with_diagnostics(
retrieval_question,
answer_mode=answer_mode,
)
diagnostics["retrieval_question"] = retrieval_question
diagnostics["conversation_mode"] = conversational
if diagnostics.get("should_refuse_generation"):
return {
"answer_mode": answer_mode,
"docs": [],
"diagnostics": diagnostics,
"prompt_text": "",
"total_started_at": total_started_at,
"refusal_result": build_refusal_result(diagnostics, total_started_at, answer_mode),
}
return {
"answer_mode": answer_mode,
"docs": docs,
"diagnostics": diagnostics,
"prompt_text": (
build_conversational_answer_prompt(
question,
retrieval_question,
answer_mode,
docs,
history,
)
if conversational
else build_answer_prompt(question, answer_mode, docs)
),
"total_started_at": total_started_at,
"refusal_result": None,
}
def finalize_answer_result(
answer: str,
docs: List[Document],
diagnostics: dict[str, Any],
total_started_at: float,
answer_mode: str,
) -> dict[str, Any]:
grounding_status = classify_grounding(answer, docs)
add_generation_diagnostics(diagnostics, total_started_at, answer_mode)
return {
"answer": answer,
"source_documents": docs,
"grounding_status": grounding_status,
"diagnostics": diagnostics,
}
def answer_question(
question: str,
answer_mode: str = "Strict Mode",
history: List[dict[str, str]] | None = None,
conversational: bool = False,
):
prepared = prepare_answer_context(
question,
answer_mode,
history,
conversational,
)
if prepared["refusal_result"] is not None:
return prepared["refusal_result"]
llm = get_llm()
response = llm.invoke(prepared["prompt_text"])
answer = getattr(response, "content", str(response))
return finalize_answer_result(
answer,
prepared["docs"],
prepared["diagnostics"],
prepared["total_started_at"],
prepared["answer_mode"],
)
def stream_answer_tokens(prompt_text: str) -> Iterator[str]:
for chunk in get_llm().stream(prompt_text):
text = getattr(chunk, "content", "")
if isinstance(text, list):
text = "".join(str(part) for part in text)
if text:
yield str(text)
def stream_answer_question(
question: str,
answer_mode: str = "Strict Mode",
history: List[dict[str, str]] | None = None,
conversational: bool = False,
) -> Iterator[dict[str, Any]]:
prepared = prepare_answer_context(
question,
answer_mode,
history,
conversational,
)
if prepared["refusal_result"] is not None:
result = prepared["refusal_result"]
yield {
"event": "metadata",
"data": {
"sources": result["source_documents"],
"diagnostics": result["diagnostics"],
"grounding_status": result["grounding_status"],
"answer_mode": prepared["answer_mode"],
},
}
yield {"event": "token", "data": {"text": result["answer"]}}
yield {"event": "done", "data": result}
return
parts = []
yield {
"event": "metadata",
"data": {
"sources": prepared["docs"],
"diagnostics": prepared["diagnostics"],
"grounding_status": "pending",
"answer_mode": prepared["answer_mode"],
},
}
for token in stream_answer_tokens(prepared["prompt_text"]):
parts.append(token)
yield {"event": "token", "data": {"text": token}}
answer = "".join(parts)
result = finalize_answer_result(
answer,
prepared["docs"],
prepared["diagnostics"],
prepared["total_started_at"],
prepared["answer_mode"],
)
yield {"event": "done", "data": result}
def search_archives(query: str):
"""
Used by Search mode only.
Returns (documents, debug_log)
"""
debug = []
docs = []
seen = set()
chunks = load_chunks()
query_clean = normalize(query)
# Keyword search
for d in chunks:
if query_clean in d.page_content.lower():
key = d.page_content[:120]
if key not in seen:
docs.append(d)
seen.add(key)
debug.append(f"Keyword hits: {len(docs)}")
# Fallback BM25
if len(docs) < 20:
for d in get_search_bm25_retriever().invoke(query):
key = document_key(d)
if key not in seen:
docs.append(d)
seen.add(key)
debug.append(f"Total results: {len(docs)}")
return docs, debug