import os
import asyncio
import uuid
import concurrent.futures
from loguru import logger
from functools import lru_cache
from typing import Optional
from pydantic import BaseModel, Field
from duckduckgo_search import DDGS
from langchain_community.utilities import WikipediaAPIWrapper, ArxivAPIWrapper
from langchain_core.tools import StructuredTool
from langchain_core.messages import SystemMessage, HumanMessage
from langchain_huggingface import HuggingFaceEmbeddings
from langchain.prompts import PromptTemplate
from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_mistralai import ChatMistralAI
from src.config import settings
from src.database import get_supabase
from src.store import get_chunks, get_material
from .constants import (
EMBEDDING_DIM,
RAG_PROMPT_TEMPLATE_BASE,
CHAT_TITLE_PROMPT_TEMPLATE,
WIKI_TOP_K_RESULTS,
WIKI_DOC_CONTENT_CHARS_MAX,
WIKI_DOC_CONTENT_CHARS_SUMMARY,
DUCKDUCKGO_NUM_RESULTS,
DUCKDUCKGO_DOC_CONTENT_CHARS_MAX,
DUCKDUCKGO_DOC_CONTENT_CHARS_SUMMARY,
ARXIV_TOP_K_RESULTS,
ARXIV_DOC_CONTENT_CHARS_MAX,
ARXIV_DOC_CONTENT_CHARS_SUMMARY,
MEMORY_WINDOW_SIZE,
TOP_K_CHUNKS,
)
from .schemas import EmbeddingJob
# ── Embeddings ─────────────────────────────────────────
@lru_cache
def get_embedder():
return HuggingFaceEmbeddings(
model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
def store_embeddings(material_id: str, chunk_ids: list[str], chunks: list[str]):
logger.info(f"Generating embeddings for material {material_id} ({len(chunks)} chunks)...")
embedder = get_embedder()
embeddings = embedder.embed_documents(chunks)
records = [
{"chunk_id": cid, "material_id": material_id, "embedding": emb}
for cid, emb in zip(chunk_ids, embeddings)
]
db = get_supabase()
if db is None:
logger.warning("Supabase not connected — embeddings computed but NOT stored (no DB).")
return
logger.info(f"Storing {len(records)} embeddings in Supabase...")
for i in range(0, len(records), 50):
db.table("material_embeddings").insert(records[i:i + 50]).execute()
logger.info(f"Embeddings stored successfully for material {material_id}.")
def warmup_embedder():
"""Dummy forward pass to keep OpenMP/MKL thread pool alive during idle periods."""
embedder = get_embedder()
embedder.embed_documents(["warmup"])
async def store_embeddings_async(material_id: str, chunk_ids: list[str], chunks: list[str]):
"""
Async variant of store_embeddings that routes embedding inference through
the batch worker queue for batching across concurrent requests.
Falls back to the synchronous path if the batch workers are not running.
"""
try:
from src.rag.batch_workers import embedding_queue, job_store
from .schemas import EmbeddingJob
job = EmbeddingJob(job_id=str(uuid.uuid4()), texts=chunks)
job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
await embedding_queue.put(job)
# Wait for the worker to process the job, but with a timeout.
# If batch workers are not running (e.g. commented out in main.py),
# this would hang forever — the timeout triggers a fallback to the sync path.
try:
await asyncio.wait_for(job.done.wait(), timeout=30.0)
except asyncio.TimeoutError:
# Workers not running — clean up and fall back to sync embedding
job_store.pop(job.job_id, None)
logger.warning(
f"store_embeddings_async timed out waiting for batch worker "
f"(material={material_id}). Falling back to synchronous embedding."
)
await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
return
entry = job_store.pop(job.job_id)
if entry["status"] == "error":
raise RuntimeError(f"Embedding failed: {entry['error']}")
embeddings = entry["result"]
records = [
{"chunk_id": cid, "material_id": material_id, "embedding": emb}
for cid, emb in zip(chunk_ids, embeddings)
]
db = get_supabase()
if db is None:
logger.warning("Supabase not connected — embeddings computed but NOT stored (no DB).")
return
logger.info(f"Storing {len(records)} embeddings in Supabase for material {material_id}...")
def _insert_records():
for i in range(0, len(records), 50):
db.table("material_embeddings").insert(records[i:i + 50]).execute()
await asyncio.to_thread(_insert_records)
logger.info(f"Embeddings stored successfully for material {material_id}.")
except Exception as e:
# If anything unexpected fails, fall back to sync to avoid blocking the caller.
logger.warning(f"store_embeddings_async failed ({e}); falling back to sync.")
await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
embedder = get_embedder()
query_embedding = embedder.embed_query(query)
db = get_supabase()
if db is None:
return []
result = db.rpc(
"match_material_chunks",
{
"query_embedding": query_embedding,
"match_material_id": material_id,
"match_threshold": 0.35,
"match_count": k,
},
).execute()
return result.data
# ── LLM ────────────────────────────────────────────────
def get_llm():
"""RAG Chatbot LLM — strictly uses Mistral AI ministral-8b-latest."""
mistral_key = os.environ.get("MISTRAL_API_KEY")
if not mistral_key:
raise ValueError("MISTRAL_API_KEY is not configured in config.env. Required for Ministral 8B RAG chatbot.")
logger.info("Initializing RAG Chatbot LLM with Mistral AI model: ministral-8b-latest")
return ChatMistralAI(
model="ministral-8b-latest",
api_key=mistral_key,
temperature=0.3,
max_tokens=3500,
timeout=120,
)
def get_summary_llm():
"""Primary Summary Generator LLM using gemini-3.5-flash-lite."""
gemini_key = os.environ.get("GEMINI_API_KEY")
if not gemini_key:
raise ValueError("GEMINI_API_KEY not found in config.env.")
logger.info("Initializing Summary LLM with model: gemini-3.5-flash-lite")
return ChatGoogleGenerativeAI(
model="gemini-3.5-flash-lite",
api_key=gemini_key,
temperature=0.3,
max_output_tokens=4000,
timeout=180,
)
def get_summary_fallback_llm():
"""Fallback Summary Generator LLM using gemini-3.1-flash-lite."""
gemini_key = os.environ.get("GEMINI_API_KEY")
if not gemini_key:
raise ValueError("GEMINI_API_KEY not found in config.env.")
logger.info("Initializing Fallback Summary LLM with model: gemini-3.1-flash-lite")
return ChatGoogleGenerativeAI(
model="gemini-3.1-flash-lite",
api_key=gemini_key,
temperature=0.3,
max_output_tokens=4000,
timeout=180,
)
def get_quiz_llm():
"""Primary Quiz Generator LLM using gemini-3.5-flash-lite."""
gemini_key = os.environ.get("GEMINI_API_KEY")
if not gemini_key:
raise ValueError("GEMINI_API_KEY not found in config.env.")
logger.info("Initializing Quiz LLM with model: gemini-3.5-flash-lite")
return ChatGoogleGenerativeAI(
model="gemini-3.5-flash-lite",
api_key=gemini_key,
temperature=0.3,
max_output_tokens=12000,
timeout=300,
)
def get_quiz_fallback_llm():
"""Fallback Quiz Generator LLM using gemini-3.1-flash-lite."""
gemini_key = os.environ.get("GEMINI_API_KEY")
if not gemini_key:
raise ValueError("GEMINI_API_KEY not found in config.env.")
logger.info("Initializing Fallback Quiz LLM with model: gemini-3.1-flash-lite")
return ChatGoogleGenerativeAI(
model="gemini-3.1-flash-lite",
api_key=gemini_key,
temperature=0.3,
max_output_tokens=12000,
timeout=300,
)
def get_fallback_llm():
"""Fallback LLM using Gemini 3.1 Flash Lite."""
return get_summary_fallback_llm()
def _clean_llm_response(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for part in content:
if isinstance(part, str):
texts.append(part)
elif isinstance(part, dict):
if part.get("type") == "text":
texts.append(part.get("text", ""))
elif "text" in part and part.get("type") != "thinking":
texts.append(part.get("text", ""))
return "\n".join(t for t in texts if t)
return str(content)
# ── Web Search Helpers ────────────────────────────────
def direct_ddg_search(query: str, max_chars: int = DUCKDUCKGO_DOC_CONTENT_CHARS_MAX) -> str:
"""
Run a DuckDuckGo web search for *query*, trying multiple backends in order.
Falls back from html → lite → api until a backend returns results.
Returns an empty string if all backends fail.
"""
for backend in ("html", "lite", "api"):
try:
ddgs = DDGS()
results = list(ddgs.text(query, max_results=DUCKDUCKGO_NUM_RESULTS, backend=backend))
if not results:
logger.warning(f"direct_ddg_search backend='{backend}': 0 results for '{query[:60]}'")
continue
snippets = [
f"{r.get('title', '')}: {r.get('body', '')}"
for r in results
if r.get('body')
]
combined = "\n".join(snippets)
if not combined.strip():
continue
logger.info(f"direct_ddg_search: {len(results)} results via backend='{backend}' chars={len(combined)}")
return combined[:max_chars]
except Exception as e:
logger.warning(f"direct_ddg_search backend='{backend}' failed: {type(e).__name__}: {e}")
continue
logger.warning(f"direct_ddg_search: all backends failed for '{query[:60]}'")
return ""
def direct_wiki_search(query: str, max_chars: int = WIKI_DOC_CONTENT_CHARS_MAX) -> str:
"""
Run a targeted Wikipedia search for *query*.
Used ONLY for topic-type materials (no PDF/URL).
Returns an empty string if the search fails.
"""
try:
wiki_api = WikipediaAPIWrapper(
top_k_results=WIKI_TOP_K_RESULTS,
doc_content_chars_max=max_chars,
)
result = wiki_api.run(query)
return result[:max_chars]
except Exception as e:
logger.warning(f"direct_wiki_search failed: {e}")
return ""
def direct_arxiv_search(query: str, max_chars: int = ARXIV_DOC_CONTENT_CHARS_MAX) -> str:
"""
Run an ArXiv paper search for *query*.
Invoked only when the agentic router classifies the topic as scientific/technical.
Returns an empty string if the search fails.
"""
try:
arxiv_api = ArxivAPIWrapper(
top_k_results=ARXIV_TOP_K_RESULTS,
doc_content_chars_max=max_chars,
)
result = arxiv_api.run(query)
return result[:max_chars]
except Exception as e:
logger.warning(f"direct_arxiv_search failed: {e}")
return ""
# ── Agentic Tool Definitions & Router ──────────────────
class _SearchInput(BaseModel):
query: str = Field(description="The exact search query string to look up")
def _make_search_tools(is_topic: bool) -> list:
"""
Build the LangChain StructuredTool list available to the agentic router.
- Topics → Wikipedia + DuckDuckGo + ArXiv (agent picks the right ones)
- PDF/URL → DuckDuckGo + ArXiv only (vector search already covers the doc)
"""
wiki_tool = StructuredTool(
name="wikipedia_search",
func=direct_wiki_search,
args_schema=_SearchInput,
description=(
"Search Wikipedia for encyclopedic, well-established knowledge. "
"This is your go-to source for foundational definitions, historical context, "
"scientific principles, biographies, and any topic with broad public documentation. "
"Use this when the query involves a recognized concept, person, event, field of study, "
"or any subject that a general-purpose encyclopedia would cover authoritatively. "
"Do NOT use for cutting-edge research not yet documented in Wikipedia, "
"real-time events, or highly niche technical subjects where ArXiv is superior."
),
)
ddg_tool = StructuredTool(
name="web_search",
func=direct_ddg_search,
args_schema=_SearchInput,
description=(
"Search the live web using DuckDuckGo to retrieve current, diverse, and up-to-date "
"information from across the internet. "
"This is your broadest and most versatile retrieval tool — use it to find recent "
"developments, practical tutorials, software documentation, real-world examples, "
"news, and any topic that benefits from multiple diverse perspectives. "
"Always consider this tool — it fills the gaps left by encyclopedias and academic papers, "
"and it excels at contemporary, applied, or rapidly evolving subjects."
),
)
arxiv_tool = StructuredTool(
name="arxiv_search",
func=direct_arxiv_search,
args_schema=_SearchInput,
description=(
"Search ArXiv for peer-reviewed preprints and cutting-edge academic research papers. "
"This tool delivers research-grade, technically precise content from the world's "
"leading open-access scientific repository. "
"Use this ONLY when the topic is clearly within an active scientific or technical domain, "
"such as: machine learning, deep learning, LLMs, computer vision, NLP, reinforcement learning, "
"physics, quantum computing, mathematics, statistics, biology, genomics, chemistry, "
"neuroscience, or any subject with a strong published academic literature. "
"Do NOT use for general knowledge, history, geography, language learning, social sciences, "
"or everyday topics that lack a formal research literature."
),
)
if is_topic:
return [wiki_tool, ddg_tool, arxiv_tool]
else:
# PDF/URL: document covers domain knowledge; only supplement with web/arxiv if needed
return [ddg_tool, arxiv_tool]
def _agentic_gather_web_content(
query: str,
is_topic: bool,
existing_doc_context: str = "",
subject_title: str = "",
) -> tuple[str, bool, bool, bool]:
"""
Core agentic routing engine.
Uses a fast LLM to decide *which* search tools (if any) are worth calling
for this specific query, then executes the selected tools in parallel.
Args:
query: The user's question or study topic.
is_topic: True for custom topic materials; False for PDF/URL.
existing_doc_context: For PDF/URL chatbot — the already-retrieved vector chunks
so the router can decide if web enrichment is needed.
subject_title: Material title, appended to web search queries for accuracy.
Returns:
(combined_content, has_wiki, has_ddg, has_arxiv)
"""
tools = _make_search_tools(is_topic)
tool_map = {
"wikipedia_search": (direct_wiki_search, "Wikipedia"),
"web_search": (direct_ddg_search, "Web Search"),
"arxiv_search": (direct_arxiv_search, "ArXiv Research"),
}
# ── Router LLM call ──────────────────────────────────────────────────
try:
router_llm = get_summary_llm()
llm_with_tools = router_llm.bind_tools(tools)
if is_topic:
messages = [
SystemMessage(content=(
"You are an expert AI educational research router. "
"Your job is to select search tools to gather comprehensive background information for a study topic. "
"You MUST select and invoke search tools for any topic. "
"Call web_search for up-to-date web content. "
"Call wikipedia_search for foundational encyclopedic knowledge. "
"Call arxiv_search ONLY if the topic involves scientific, academic, machine learning, physics, math, AI, or technical research."
)),
HumanMessage(content=f'Study topic to research: "{query}"'),
]
else:
doc_preview = existing_doc_context[:600].strip() if existing_doc_context else ""
messages = [
SystemMessage(content=(
"You are an AI research router evaluating a user query against existing document context. "
"Decide whether external search tools are needed to supplement the document context. "
"If the document context is sufficient to answer the query, do NOT call any tools. "
"If the query asks for current info, recent software versions, releases, real-time facts, "
"or topics missing from the document context, you MUST call web_search. "
"If the query asks for academic paper research or cutting-edge scientific methods, call arxiv_search."
)),
HumanMessage(content=(
f'User query: "{query}"\n'
f'Subject: "{subject_title}"\n'
f'Document context retrieved:\n{doc_preview}'
)),
]
router_response = llm_with_tools.invoke(messages)
selected_calls = getattr(router_response, "tool_calls", []) or []
except Exception as e:
logger.warning(f"Agentic router LLM failed ({e}); falling back to default tool selection.")
selected_calls = []
# Guarantee search tool execution for custom topics if LLM didn't emit calls
if is_topic and not selected_calls:
logger.info(f"Agentic router emitted no calls for topic '{query}'; activating intelligent fallback.")
academic_keywords = {"llm", "lora", "quantum", "model", "neural", "transformer", "algorithm", "deep learning", "ai", "physics", "math", "genomics", "arxiv"}
q_lower = query.lower()
is_academic = any(kw in q_lower for kw in academic_keywords)
selected_calls = [
{"name": "wikipedia_search", "args": {"query": query}},
{"name": "web_search", "args": {"query": f"{query} {subject_title}".strip()}},
]
if is_academic:
selected_calls.append({"name": "arxiv_search", "args": {"query": query}})
# For PDF/URL materials: if query asks for current/latest/external info and router returned 0 tools, fallback to web_search
if not is_topic and not selected_calls:
external_keywords = {"latest", "current", "recent", "release", "version", "today", "news", "price", "2025", "2026"}
q_lower = query.lower()
if any(kw in q_lower for kw in external_keywords):
logger.info(f"External/temporal query detected for PDF material '{query}'; activating web_search fallback.")
selected_calls = [{"name": "web_search", "args": {"query": query}}]
if not selected_calls:
logger.info(f"Agentic router: no tools selected for query='{query}' is_topic={is_topic}")
return "", False, False, False
# ── Parallel tool execution ───────────────────────────────────────────
context_parts: list[str] = []
has_wiki = has_ddg = has_arxiv = False
# Determine higher character limits for Topic Creation (Gemini 3.5 Flash Lite - high context)
wiki_max = WIKI_DOC_CONTENT_CHARS_SUMMARY if is_topic else WIKI_DOC_CONTENT_CHARS_MAX
ddg_max = DUCKDUCKGO_DOC_CONTENT_CHARS_SUMMARY if is_topic else DUCKDUCKGO_DOC_CONTENT_CHARS_MAX
arxiv_max = ARXIV_DOC_CONTENT_CHARS_SUMMARY if is_topic else ARXIV_DOC_CONTENT_CHARS_MAX
def _run_tool(tool_call: dict) -> tuple[str, str, str]:
name = tool_call.get("name", "")
args = tool_call.get("args", {})
raw_query = args.get("query", query)
# Enrich DuckDuckGo query with subject title only for short queries that lack subject context
if name == "web_search" and subject_title:
if len(raw_query.split()) < 4 and subject_title.lower() not in raw_query.lower():
raw_query = f"{raw_query} {subject_title}".strip()
if name == "wikipedia_search":
return name, "Wikipedia", direct_wiki_search(raw_query, max_chars=wiki_max)
elif name == "web_search":
return name, "Web Search", direct_ddg_search(raw_query, max_chars=ddg_max)
elif name == "arxiv_search":
return name, "ArXiv Research", direct_arxiv_search(raw_query, max_chars=arxiv_max)
return name, "", ""
with concurrent.futures.ThreadPoolExecutor(max_workers=len(selected_calls)) as executor:
futures = [executor.submit(_run_tool, tc) for tc in selected_calls]
for fut in concurrent.futures.as_completed(futures):
try:
name, label, result = fut.result(timeout=20)
if result.strip():
context_parts.append(f"--- {label} ---\n{result}")
if name == "wikipedia_search": has_wiki = True
elif name == "web_search": has_ddg = True
elif name == "arxiv_search": has_arxiv = True
except Exception as e:
logger.warning(f"Tool execution error: {e}")
combined = "\n\n".join(context_parts)
logger.info(
f"Agentic web gather done — wiki={has_wiki} ddg={has_ddg} arxiv={has_arxiv} "
f"chars={len(combined)} for query='{query}'"
)
return combined, has_wiki, has_ddg, has_arxiv
# ── Supabase Retriever ────────────────
class SupabaseRetriever(BaseRetriever):
material_id: str
k: int = TOP_K_CHUNKS
def _get_relevant_documents(self, query: str) -> list[Document]:
results = similarity_search(query, self.material_id, self.k)
return [
Document(page_content=r["content"], metadata={
"similarity": r.get("similarity"),
"chunk_id": r.get("chunk_id"),
})
for r in results
]
# ── RAG Prompt ─────────────────────────────────────────
def _rag_prompt(
has_ddg: bool = False,
has_wiki: bool = False,
has_arxiv: bool = False,
has_knowledge_retriever: bool = False,
subject: str = "",
):
sources = []
if has_wiki:
sources.append("Wikipedia snippets")
if has_ddg:
sources.append("DuckDuckGo web snippets")
if has_arxiv:
sources.append("ArXiv research papers")
tools_section = ""
if sources:
tools_section = (
f"\n\n"
f"The following search results ({', '.join(sources)}) were retrieved specifically "
f"for this query and are included in .\n"
f""
)
if has_knowledge_retriever:
tools_section += (
"\n\n"
"Relevant excerpts from the user's learning material are also included in .\n"
""
)
subject_line = f"\nYour current study topic is: **{subject}**." if subject else ""
formatted_template = RAG_PROMPT_TEMPLATE_BASE.format(
subject_line=subject_line,
tools_section=tools_section
)
return PromptTemplate(
input_variables=["chat_history", "input", "agent_scratchpad", "context"],
template=formatted_template,
)
# ── RAG Answer ─────────────────────────────────────────
def rag_answer(
query: str,
material_id: Optional[str] = None,
chunks: Optional[list[str]] = None,
summaries: str = "",
memory = None,
):
if memory is None:
memory = ConversationBufferWindowMemory(
input_key="input", memory_key="chat_history", return_messages=True, k=MEMORY_WINDOW_SIZE
)
# Fetch material info if material_id is provided
mat = None
if material_id:
mat = get_material(material_id)
is_topic = not (material_id and mat and mat.get("source_type") != "topic")
context_parts = []
has_chunks = False
# Inject Subject/Topic
if mat and mat.get("title"):
context_parts.append(f"Subject / Topic: {mat.get('title')}")
if not is_topic:
# --- Material-based query (PDF/URL): vector similarity search ---
results = similarity_search(query, material_id, k=TOP_K_CHUNKS)
if results:
has_chunks = True
chunks = [r["content"] for r in results]
context_parts.append("Relevant Excerpts:\n" + "\n---\n".join(chunks))
# Fallback: summary
if not has_chunks and summaries:
context_parts.append(f"Material Summary (No specific excerpts found for your query):\n{summaries}")
# Fallback: sample head + tail chunks
if not has_chunks and not summaries:
all_chunks = get_chunks(material_id)
if all_chunks:
head = all_chunks[:3]
tail = all_chunks[-2:] if len(all_chunks) > 3 else []
sampled = head + [c for c in tail if c not in head]
sampled_text = "\n---\n".join(c["content"] for c in sampled)
context_parts.append(f"Material Sample (No summary found; showing start and end of material):\n{sampled_text}")
subject_title = mat.get("title") if mat and mat.get("title") else ""
# Build existing doc context string for the router (PDF/URL only)
existing_doc_context = "\n\n".join(context_parts) if (not is_topic and context_parts) else ""
# --- Agentic web context gathering ---
web_content, has_wiki, has_ddg, has_arxiv = _agentic_gather_web_content(
query=query,
is_topic=is_topic,
existing_doc_context=existing_doc_context,
subject_title=subject_title,
)
if web_content:
context_parts.append(web_content)
context_str = "\n\n".join(context_parts) if context_parts else "No specific context provided."
has_knowledge = not is_topic
prompt = _rag_prompt(
has_ddg=has_ddg,
has_wiki=has_wiki,
has_arxiv=has_arxiv,
has_knowledge_retriever=has_knowledge,
subject=subject_title,
)
# Safety/refusal responses must NOT be saved to memory, otherwise the
_REFUSAL_PREFIXES = (
"I can't respond on a gibberish",
"I can't respond on a NSFW",
"I can't respond on a political",
"I can't respond on a religious",
)
def _is_refusal(text: str) -> bool:
t = text.strip()
return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
try:
primary_llm = get_llm()
chain = prompt | primary_llm
memory_vars = memory.load_memory_variables({"input": query})
chat_history = memory_vars.get("chat_history", [])
response = chain.invoke({
"input": query,
"context": context_str,
"chat_history": chat_history,
"agent_scratchpad": "",
})
answer = _clean_llm_response(response.content)
# Only persist non-refusal answers to memory
if not _is_refusal(answer):
memory.save_context({"input": query}, {"output": answer})
return answer, memory
except Exception as e:
logger.warning(f"Primary LLM call failed or rate-limited: {e}. Falling back to secondary LLM.")
try:
fallback_llm = get_fallback_llm()
chain = prompt | fallback_llm
memory_vars = memory.load_memory_variables({"input": query})
chat_history = memory_vars.get("chat_history", [])
response = chain.invoke({
"input": query,
"context": context_str,
"chat_history": chat_history,
"agent_scratchpad": "",
})
answer = _clean_llm_response(response.content)
# Only persist non-refusal answers to memory
if not _is_refusal(answer):
memory.save_context({"input": query}, {"output": answer})
return answer, memory
except Exception as fallback_err:
logger.error(f"Fallback LLM call also failed: {fallback_err}")
raise fallback_err
async def rag_answer_stream(
query: str,
material_id: Optional[str] = None,
chunks: Optional[list[str]] = None,
summaries: str = "",
memory = None,
):
"""
Async generator that streams LLM response tokens using Mistral AI (with Gemini fallback).
Yields individual token strings as they arrive.
Updates conversation memory upon completion if not a refusal.
"""
if memory is None:
memory = ConversationBufferWindowMemory(
input_key="input", memory_key="chat_history", return_messages=True, k=MEMORY_WINDOW_SIZE
)
# Fetch material info if material_id is provided
mat = None
if material_id:
mat = await asyncio.to_thread(get_material, material_id)
is_topic = not (material_id and mat and mat.get("source_type") != "topic")
context_parts = []
has_chunks = False
# Inject Subject/Topic
if mat and mat.get("title"):
context_parts.append(f"Subject / Topic: {mat.get('title')}")
if not is_topic:
# --- Material-based query (PDF/URL): vector similarity search ---
results = await asyncio.to_thread(similarity_search, query, material_id, k=TOP_K_CHUNKS)
if results:
has_chunks = True
chunks = [r["content"] for r in results]
context_parts.append("Relevant Excerpts:\n" + "\n---\n".join(chunks))
# Fallback: summary
if not has_chunks and summaries:
context_parts.append(f"Material Summary (No specific excerpts found for your query):\n{summaries}")
# Fallback: sample head + tail chunks
if not has_chunks and not summaries:
all_chunks = await asyncio.to_thread(get_chunks, material_id)
if all_chunks:
head = all_chunks[:3]
tail = all_chunks[-2:] if len(all_chunks) > 3 else []
sampled = head + [c for c in tail if c not in head]
sampled_text = "\n---\n".join(c["content"] for c in sampled)
context_parts.append(f"Material Sample (No summary found; showing start and end of material):\n{sampled_text}")
subject_title = mat.get("title") if mat and mat.get("title") else ""
# Build existing doc context string for the router (PDF/URL only)
existing_doc_context = "\n\n".join(context_parts) if (not is_topic and context_parts) else ""
# --- Agentic web context gathering (runs in thread to avoid blocking event loop) ---
web_content, has_wiki, has_ddg, has_arxiv = await asyncio.to_thread(
_agentic_gather_web_content,
query,
is_topic,
existing_doc_context,
subject_title,
)
if web_content:
context_parts.append(web_content)
context_str = "\n\n".join(context_parts) if context_parts else "No specific context provided."
has_knowledge = not is_topic
prompt = _rag_prompt(
has_ddg=has_ddg,
has_wiki=has_wiki,
has_arxiv=has_arxiv,
has_knowledge_retriever=has_knowledge,
subject=subject_title,
)
_REFUSAL_PREFIXES = (
"I can't respond on a gibberish",
"I can't respond on a NSFW",
"I can't respond on a political",
"I can't respond on a religious",
)
def _is_refusal(text: str) -> bool:
t = text.strip()
return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
memory_vars = memory.load_memory_variables({"input": query})
chat_history = memory_vars.get("chat_history", [])
full_answer_parts = []
try:
primary_llm = get_llm()
chain = prompt | primary_llm
async for chunk in chain.astream({
"input": query,
"context": context_str,
"chat_history": chat_history,
"agent_scratchpad": "",
}):
token = _clean_llm_response(chunk.content)
if token:
full_answer_parts.append(token)
yield token
except Exception as e:
logger.warning(f"Primary LLM streaming failed or rate-limited: {e}. Falling back to secondary LLM.")
try:
fallback_llm = get_fallback_llm()
chain = prompt | fallback_llm
async for chunk in chain.astream({
"input": query,
"context": context_str,
"chat_history": chat_history,
"agent_scratchpad": "",
}):
token = _clean_llm_response(chunk.content)
if token:
full_answer_parts.append(token)
yield token
except Exception as fallback_err:
logger.error(f"Fallback LLM streaming failed: {fallback_err}")
raise fallback_err
full_answer = "".join(full_answer_parts)
if not _is_refusal(full_answer):
memory.save_context({"input": query}, {"output": full_answer})
def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
topic_context = ""
if material_title:
topic_context = f"\nNote: The user is discussing the topic '{material_title}'. If their query uses pronouns like 'its' or 'this', assume it refers to this topic. If the topic name '{material_title}' appears to be a random string or dummy name, do not use it directly; instead, create a general title related to their query, such as 'Types of the topic' or 'Elements of the topic'."
formatted_template = CHAT_TITLE_PROMPT_TEMPLATE.format(topic_context=topic_context)
prompt = PromptTemplate(
input_variables=["query"],
template=formatted_template
)
try:
primary_llm = get_llm()
chain = prompt | primary_llm
response = chain.invoke({"query": query})
except Exception as e:
logger.warning(f"Primary LLM call failed in extract_chat_title: {e}. Falling back to secondary LLM.")
try:
fallback_llm = get_fallback_llm()
chain = prompt | fallback_llm
response = chain.invoke({"query": query})
except Exception as fallback_err:
logger.error(f"Fallback LLM call also failed in extract_chat_title: {fallback_err}")
raise fallback_err
raw_title = _clean_llm_response(response.content)
# Strip markdown symbols (*, #, _, `, quotes)
title = raw_title.replace('*', '').replace('#', '').replace('_', '').replace('`', '').strip().strip('"').strip("'")
if len(title) > 50:
title = title[:50].rsplit(' ', 1)[0] + '...'
return title