Spaces:
Configuration error
Configuration error
Download src/app.py from Adoption/7th_handle: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/spaces/Adoption/7th_handle/resolve/main/src/app.py
- Command line
-
hf download hf://spaces/Adoption/7th_handle/src/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Adoption/7th_handle/resolve/main/src/app.py
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() | |
| def load_chunks() -> List[Document]: | |
| if not os.path.exists(CHUNKS_FILE): | |
| return [] | |
| with open(CHUNKS_FILE, "rb") as f: | |
| return pickle.load(f) | |
| def get_bm25_retriever(): | |
| bm25 = BM25Retriever.from_documents(load_chunks()) | |
| bm25.k = BM25_K | |
| return bm25 | |
| def get_search_bm25_retriever(): | |
| bm25 = BM25Retriever.from_documents(load_chunks()) | |
| bm25.k = 50 | |
| return bm25 | |
| 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 | |
| # =============================== | |
| 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 | |