Spaces:
Running
Running
feat: implement LLM fallback for quiz generation and enhance OAuth error handling
Browse files- config.py +3 -0
- main.py +5 -1
- materials/text_utils.py +16 -4
- quiz_generator/quiz.py +112 -72
- rag/constants.py +4 -4
- rag/rag.py +75 -50
- requirements.txt +2 -0
- store.py +2 -1
- summary_generator/summary.py +25 -18
config.py
CHANGED
|
@@ -11,6 +11,7 @@ load_dotenv(ENV_PATH)
|
|
| 11 |
|
| 12 |
class Settings:
|
| 13 |
gemini_api_key: str = os.getenv("GEMINI_API_KEY", "")
|
|
|
|
| 14 |
# Accept both plain and NEXT_PUBLIC_ prefixed names (config.env uses NEXT_PUBLIC_)
|
| 15 |
supabase_url: str = (
|
| 16 |
os.getenv("SUPABASE_URL")
|
|
@@ -60,6 +61,8 @@ def get_settings() -> Settings:
|
|
| 60 |
s = Settings()
|
| 61 |
if s.gemini_api_key:
|
| 62 |
os.environ["GEMINI_API_KEY"] = s.gemini_api_key
|
|
|
|
|
|
|
| 63 |
if "TRANSFORMERS_NO_TF" not in os.environ and s.transformers_no_tf:
|
| 64 |
os.environ["TRANSFORMERS_NO_TF"] = s.transformers_no_tf
|
| 65 |
return s
|
|
|
|
| 11 |
|
| 12 |
class Settings:
|
| 13 |
gemini_api_key: str = os.getenv("GEMINI_API_KEY", "")
|
| 14 |
+
groq_api_key: str = os.getenv("GROQ_API_KEY", "")
|
| 15 |
# Accept both plain and NEXT_PUBLIC_ prefixed names (config.env uses NEXT_PUBLIC_)
|
| 16 |
supabase_url: str = (
|
| 17 |
os.getenv("SUPABASE_URL")
|
|
|
|
| 61 |
s = Settings()
|
| 62 |
if s.gemini_api_key:
|
| 63 |
os.environ["GEMINI_API_KEY"] = s.gemini_api_key
|
| 64 |
+
if s.groq_api_key:
|
| 65 |
+
os.environ["GROQ_API_KEY"] = s.groq_api_key
|
| 66 |
if "TRANSFORMERS_NO_TF" not in os.environ and s.transformers_no_tf:
|
| 67 |
os.environ["TRANSFORMERS_NO_TF"] = s.transformers_no_tf
|
| 68 |
return s
|
main.py
CHANGED
|
@@ -47,6 +47,10 @@ root_logger.setLevel(logging.INFO)
|
|
| 47 |
root_logger.addHandler(file_handler)
|
| 48 |
root_logger.addHandler(console_handler)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
logger = logging.getLogger(__name__)
|
| 51 |
|
| 52 |
|
|
@@ -85,7 +89,7 @@ app = FastAPI(
|
|
| 85 |
title="AI Tutor API",
|
| 86 |
description="Backend API for the AI Tutor for Students application",
|
| 87 |
version="1.0.0",
|
| 88 |
-
|
| 89 |
)
|
| 90 |
|
| 91 |
@app.middleware("http")
|
|
|
|
| 47 |
root_logger.addHandler(file_handler)
|
| 48 |
root_logger.addHandler(console_handler)
|
| 49 |
|
| 50 |
+
# Suppress noisy watchfiles reload logs
|
| 51 |
+
logging.getLogger("watchfiles").setLevel(logging.WARNING)
|
| 52 |
+
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
| 53 |
+
|
| 54 |
logger = logging.getLogger(__name__)
|
| 55 |
|
| 56 |
|
|
|
|
| 89 |
title="AI Tutor API",
|
| 90 |
description="Backend API for the AI Tutor for Students application",
|
| 91 |
version="1.0.0",
|
| 92 |
+
lifespan=lifespan,
|
| 93 |
)
|
| 94 |
|
| 95 |
@app.middleware("http")
|
materials/text_utils.py
CHANGED
|
@@ -3,22 +3,34 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
| 3 |
from langchain_community.document_loaders import UnstructuredURLLoader
|
| 4 |
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
def text_from_pdf(pdf_file) -> str:
|
| 7 |
reader = PyPDF2.PdfReader(pdf_file)
|
| 8 |
text = ""
|
| 9 |
for page in reader.pages:
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 150):
|
|
|
|
| 15 |
splitter = RecursiveCharacterTextSplitter(
|
| 16 |
chunk_size=chunk_size, chunk_overlap=chunk_overlap
|
| 17 |
)
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def scrap_website(url: str) -> str:
|
| 22 |
loader = UnstructuredURLLoader(urls=[url], ssl_verify=True)
|
| 23 |
data = loader.load()
|
| 24 |
-
|
|
|
|
|
|
| 3 |
from langchain_community.document_loaders import UnstructuredURLLoader
|
| 4 |
|
| 5 |
|
| 6 |
+
def _clean_text(text: str) -> str:
|
| 7 |
+
if not text:
|
| 8 |
+
return ""
|
| 9 |
+
# Remove NULL bytes (\x00 / \u0000) which PostgreSQL text format cannot accept
|
| 10 |
+
return text.replace("\x00", "").replace("\u0000", "")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
def text_from_pdf(pdf_file) -> str:
|
| 14 |
reader = PyPDF2.PdfReader(pdf_file)
|
| 15 |
text = ""
|
| 16 |
for page in reader.pages:
|
| 17 |
+
extracted = page.extract_text()
|
| 18 |
+
if extracted:
|
| 19 |
+
text += extracted
|
| 20 |
+
return _clean_text(text)
|
| 21 |
|
| 22 |
|
| 23 |
def chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 150):
|
| 24 |
+
cleaned = _clean_text(text)
|
| 25 |
splitter = RecursiveCharacterTextSplitter(
|
| 26 |
chunk_size=chunk_size, chunk_overlap=chunk_overlap
|
| 27 |
)
|
| 28 |
+
chunks = splitter.split_text(cleaned)
|
| 29 |
+
return [_clean_text(c) for c in chunks if _clean_text(c).strip()]
|
| 30 |
|
| 31 |
|
| 32 |
def scrap_website(url: str) -> str:
|
| 33 |
loader = UnstructuredURLLoader(urls=[url], ssl_verify=True)
|
| 34 |
data = loader.load()
|
| 35 |
+
raw = data[0].page_content if data else ""
|
| 36 |
+
return _clean_text(raw)
|
quiz_generator/quiz.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Optional
|
|
| 6 |
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
| 7 |
from langchain_core.tools import create_retriever_tool
|
| 8 |
|
| 9 |
-
from src.rag.rag import get_quiz_llm, SupabaseRetriever
|
| 10 |
from .constants import (
|
| 11 |
QUIZ_PROMPT_TEMPLATE,
|
| 12 |
WEB_QUIZ_PROMPT_TEMPLATE,
|
|
@@ -58,11 +58,11 @@ def smart_quiz_generator(
|
|
| 58 |
|
| 59 |
def _summary_quiz(difficulty, mcq_count, tf_count, context_text):
|
| 60 |
logger.info(f"Summary Quiz started (diff={difficulty}, mcq={mcq_count}, tf={tf_count})")
|
|
|
|
|
|
|
|
|
|
| 61 |
try:
|
| 62 |
-
prompt = _quiz_prompt()
|
| 63 |
llm = get_quiz_llm()
|
| 64 |
-
safe_context = context_text
|
| 65 |
-
|
| 66 |
chain = prompt | llm
|
| 67 |
response = chain.invoke({
|
| 68 |
"difficulty": difficulty,
|
|
@@ -72,30 +72,39 @@ def _summary_quiz(difficulty, mcq_count, tf_count, context_text):
|
|
| 72 |
"context": safe_context,
|
| 73 |
"agent_scratchpad": "",
|
| 74 |
})
|
| 75 |
-
|
| 76 |
-
raw_content = response.content
|
| 77 |
-
logger.info(f"Summary Quiz received response of length {len(raw_content)}")
|
| 78 |
-
|
| 79 |
-
# response is a message object, content is the text
|
| 80 |
-
return _parse_quiz({"output": raw_content})
|
| 81 |
except Exception as e:
|
| 82 |
-
logger.
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
def _contextual_quiz(difficulty, mcq_count, tf_count, context, material_id):
|
| 87 |
logger.info(f"Contextual Quiz started (material_id={material_id}, diff={difficulty})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
try:
|
| 89 |
-
prompt = _quiz_prompt()
|
| 90 |
llm = get_quiz_llm()
|
| 91 |
-
|
| 92 |
-
retriever = SupabaseRetriever(material_id=material_id, k=RETRIEVER_K)
|
| 93 |
-
retriever_tool = create_retriever_tool(
|
| 94 |
-
retriever,
|
| 95 |
-
name="quiz_material_retriever",
|
| 96 |
-
description="Retrieves relevant content from uploaded materials for quiz generation.",
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
agent = create_tool_calling_agent(llm, [retriever_tool], prompt)
|
| 100 |
executor = AgentExecutor(
|
| 101 |
agent=agent,
|
|
@@ -106,8 +115,6 @@ def _contextual_quiz(difficulty, mcq_count, tf_count, context, material_id):
|
|
| 106 |
max_iterations=80,
|
| 107 |
max_execution_time=300,
|
| 108 |
)
|
| 109 |
-
|
| 110 |
-
safe_context = context or ""
|
| 111 |
response = executor.invoke({
|
| 112 |
"difficulty": difficulty,
|
| 113 |
"source_type": "Document Embeddings",
|
|
@@ -116,57 +123,78 @@ def _contextual_quiz(difficulty, mcq_count, tf_count, context, material_id):
|
|
| 116 |
"agent_scratchpad": "",
|
| 117 |
"context": safe_context,
|
| 118 |
})
|
| 119 |
-
logger.info("Contextual Quiz agent finished successfully")
|
| 120 |
return _parse_quiz(response)
|
| 121 |
except Exception as e:
|
| 122 |
-
logger.
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def _web_quiz(difficulty, mcq_count, tf_count, topic_title):
|
| 127 |
logger.info(f"Web Quiz started (topic={topic_title}, diff={difficulty})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
try:
|
| 129 |
-
import concurrent.futures
|
| 130 |
-
from langchain_community.utilities import WikipediaAPIWrapper, DuckDuckGoSearchAPIWrapper
|
| 131 |
-
|
| 132 |
-
def fetch_wikipedia():
|
| 133 |
-
try:
|
| 134 |
-
wiki_api = WikipediaAPIWrapper(
|
| 135 |
-
top_k_results=WIKI_TOP_K_RESULTS,
|
| 136 |
-
doc_content_chars_max=WIKI_DOC_CONTENT_CHARS_MAX,
|
| 137 |
-
)
|
| 138 |
-
return wiki_api.run(topic_title)
|
| 139 |
-
except Exception as e:
|
| 140 |
-
logger.warning(f"Wikipedia search for '{topic_title}' failed: {e}")
|
| 141 |
-
return ""
|
| 142 |
-
|
| 143 |
-
def fetch_duckduckgo():
|
| 144 |
-
try:
|
| 145 |
-
duck_api = DuckDuckGoSearchAPIWrapper()
|
| 146 |
-
return duck_api.run(topic_title)
|
| 147 |
-
except Exception as e:
|
| 148 |
-
logger.warning(f"DuckDuckGo search for '{topic_title}' failed: {e}")
|
| 149 |
-
return ""
|
| 150 |
-
|
| 151 |
-
# Fetch in parallel
|
| 152 |
-
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 153 |
-
wiki_future = executor.submit(fetch_wikipedia)
|
| 154 |
-
duck_future = executor.submit(fetch_duckduckgo)
|
| 155 |
-
|
| 156 |
-
wiki_content = wiki_future.result()
|
| 157 |
-
duck_content = duck_future.result()
|
| 158 |
-
|
| 159 |
-
all_content = []
|
| 160 |
-
if wiki_content and wiki_content.strip():
|
| 161 |
-
all_content.append(f"--- Wikipedia ---\n{wiki_content}")
|
| 162 |
-
if duck_content and duck_content.strip():
|
| 163 |
-
all_content.append(f"--- Web Search ---\n{duck_content}")
|
| 164 |
-
|
| 165 |
-
combined_context = "\n\n".join(all_content) if all_content else f"No web content found for: {topic_title}"
|
| 166 |
-
|
| 167 |
-
prompt = WEB_QUIZ_PROMPT_TEMPLATE
|
| 168 |
llm = get_quiz_llm()
|
| 169 |
-
|
| 170 |
chain = prompt | llm
|
| 171 |
response = chain.invoke({
|
| 172 |
"topic": topic_title,
|
|
@@ -177,13 +205,25 @@ def _web_quiz(difficulty, mcq_count, tf_count, topic_title):
|
|
| 177 |
"source_type": "Web Search",
|
| 178 |
"agent_scratchpad": "",
|
| 179 |
})
|
| 180 |
-
|
| 181 |
-
raw_content = response.content
|
| 182 |
-
logger.info("Web Quiz chain finished successfully")
|
| 183 |
-
return _parse_quiz({"output": raw_content})
|
| 184 |
except Exception as e:
|
| 185 |
-
logger.
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
|
| 189 |
def _parse_quiz(response):
|
|
|
|
| 6 |
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
| 7 |
from langchain_core.tools import create_retriever_tool
|
| 8 |
|
| 9 |
+
from src.rag.rag import get_quiz_llm, get_quiz_fallback_llm, SupabaseRetriever
|
| 10 |
from .constants import (
|
| 11 |
QUIZ_PROMPT_TEMPLATE,
|
| 12 |
WEB_QUIZ_PROMPT_TEMPLATE,
|
|
|
|
| 58 |
|
| 59 |
def _summary_quiz(difficulty, mcq_count, tf_count, context_text):
|
| 60 |
logger.info(f"Summary Quiz started (diff={difficulty}, mcq={mcq_count}, tf={tf_count})")
|
| 61 |
+
prompt = _quiz_prompt()
|
| 62 |
+
safe_context = context_text
|
| 63 |
+
|
| 64 |
try:
|
|
|
|
| 65 |
llm = get_quiz_llm()
|
|
|
|
|
|
|
| 66 |
chain = prompt | llm
|
| 67 |
response = chain.invoke({
|
| 68 |
"difficulty": difficulty,
|
|
|
|
| 72 |
"context": safe_context,
|
| 73 |
"agent_scratchpad": "",
|
| 74 |
})
|
| 75 |
+
return _parse_quiz({"output": response.content})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
except Exception as e:
|
| 77 |
+
logger.warning(f"Primary Quiz LLM (gemini-3.5-flash-lite) failed: {e}. Falling back to gemini-3.1-flash-lite.")
|
| 78 |
+
try:
|
| 79 |
+
fallback_llm = get_quiz_fallback_llm()
|
| 80 |
+
chain = prompt | fallback_llm
|
| 81 |
+
response = chain.invoke({
|
| 82 |
+
"difficulty": difficulty,
|
| 83 |
+
"mcq_count": mcq_count,
|
| 84 |
+
"tf_count": tf_count,
|
| 85 |
+
"source_type": "summary",
|
| 86 |
+
"context": safe_context,
|
| 87 |
+
"agent_scratchpad": "",
|
| 88 |
+
})
|
| 89 |
+
return _parse_quiz({"output": response.content})
|
| 90 |
+
except Exception as fallback_err:
|
| 91 |
+
logger.error(f"Fallback Quiz generation failed: {fallback_err}", exc_info=True)
|
| 92 |
+
raise fallback_err
|
| 93 |
|
| 94 |
|
| 95 |
def _contextual_quiz(difficulty, mcq_count, tf_count, context, material_id):
|
| 96 |
logger.info(f"Contextual Quiz started (material_id={material_id}, diff={difficulty})")
|
| 97 |
+
prompt = _quiz_prompt()
|
| 98 |
+
retriever = SupabaseRetriever(material_id=material_id, k=RETRIEVER_K)
|
| 99 |
+
retriever_tool = create_retriever_tool(
|
| 100 |
+
retriever,
|
| 101 |
+
name="quiz_material_retriever",
|
| 102 |
+
description="Retrieves relevant content from uploaded materials for quiz generation.",
|
| 103 |
+
)
|
| 104 |
+
safe_context = context or ""
|
| 105 |
+
|
| 106 |
try:
|
|
|
|
| 107 |
llm = get_quiz_llm()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
agent = create_tool_calling_agent(llm, [retriever_tool], prompt)
|
| 109 |
executor = AgentExecutor(
|
| 110 |
agent=agent,
|
|
|
|
| 115 |
max_iterations=80,
|
| 116 |
max_execution_time=300,
|
| 117 |
)
|
|
|
|
|
|
|
| 118 |
response = executor.invoke({
|
| 119 |
"difficulty": difficulty,
|
| 120 |
"source_type": "Document Embeddings",
|
|
|
|
| 123 |
"agent_scratchpad": "",
|
| 124 |
"context": safe_context,
|
| 125 |
})
|
|
|
|
| 126 |
return _parse_quiz(response)
|
| 127 |
except Exception as e:
|
| 128 |
+
logger.warning(f"Primary Contextual Quiz agent failed: {e}. Falling back to gemini-3.1-flash-lite.")
|
| 129 |
+
try:
|
| 130 |
+
fallback_llm = get_quiz_fallback_llm()
|
| 131 |
+
agent = create_tool_calling_agent(fallback_llm, [retriever_tool], prompt)
|
| 132 |
+
executor = AgentExecutor(
|
| 133 |
+
agent=agent,
|
| 134 |
+
tools=[retriever_tool],
|
| 135 |
+
verbose=False,
|
| 136 |
+
return_intermediate_steps=False,
|
| 137 |
+
handle_parsing_errors=True,
|
| 138 |
+
max_iterations=80,
|
| 139 |
+
max_execution_time=300,
|
| 140 |
+
)
|
| 141 |
+
response = executor.invoke({
|
| 142 |
+
"difficulty": difficulty,
|
| 143 |
+
"source_type": "Document Embeddings",
|
| 144 |
+
"mcq_count": mcq_count,
|
| 145 |
+
"tf_count": tf_count,
|
| 146 |
+
"agent_scratchpad": "",
|
| 147 |
+
"context": safe_context,
|
| 148 |
+
})
|
| 149 |
+
return _parse_quiz(response)
|
| 150 |
+
except Exception as fallback_err:
|
| 151 |
+
logger.error(f"Fallback Contextual Quiz failed: {fallback_err}", exc_info=True)
|
| 152 |
+
raise fallback_err
|
| 153 |
|
| 154 |
|
| 155 |
def _web_quiz(difficulty, mcq_count, tf_count, topic_title):
|
| 156 |
logger.info(f"Web Quiz started (topic={topic_title}, diff={difficulty})")
|
| 157 |
+
import concurrent.futures
|
| 158 |
+
from langchain_community.utilities import WikipediaAPIWrapper, DuckDuckGoSearchAPIWrapper
|
| 159 |
+
|
| 160 |
+
def fetch_wikipedia():
|
| 161 |
+
try:
|
| 162 |
+
wiki_api = WikipediaAPIWrapper(
|
| 163 |
+
top_k_results=WIKI_TOP_K_RESULTS,
|
| 164 |
+
doc_content_chars_max=WIKI_DOC_CONTENT_CHARS_MAX,
|
| 165 |
+
)
|
| 166 |
+
return wiki_api.run(topic_title)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.warning(f"Wikipedia search for '{topic_title}' failed: {e}")
|
| 169 |
+
return ""
|
| 170 |
+
|
| 171 |
+
def fetch_duckduckgo():
|
| 172 |
+
try:
|
| 173 |
+
duck_api = DuckDuckGoSearchAPIWrapper()
|
| 174 |
+
return duck_api.run(topic_title)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.warning(f"DuckDuckGo search for '{topic_title}' failed: {e}")
|
| 177 |
+
return ""
|
| 178 |
+
|
| 179 |
+
# Fetch in parallel
|
| 180 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 181 |
+
wiki_future = executor.submit(fetch_wikipedia)
|
| 182 |
+
duck_future = executor.submit(fetch_duckduckgo)
|
| 183 |
+
|
| 184 |
+
wiki_content = wiki_future.result()
|
| 185 |
+
duck_content = duck_future.result()
|
| 186 |
+
|
| 187 |
+
all_content = []
|
| 188 |
+
if wiki_content and wiki_content.strip():
|
| 189 |
+
all_content.append(f"--- Wikipedia ---\n{wiki_content}")
|
| 190 |
+
if duck_content and duck_content.strip():
|
| 191 |
+
all_content.append(f"--- Web Search ---\n{duck_content}")
|
| 192 |
+
|
| 193 |
+
combined_context = "\n\n".join(all_content) if all_content else f"No web content found for: {topic_title}"
|
| 194 |
+
prompt = WEB_QUIZ_PROMPT_TEMPLATE
|
| 195 |
+
|
| 196 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
llm = get_quiz_llm()
|
|
|
|
| 198 |
chain = prompt | llm
|
| 199 |
response = chain.invoke({
|
| 200 |
"topic": topic_title,
|
|
|
|
| 205 |
"source_type": "Web Search",
|
| 206 |
"agent_scratchpad": "",
|
| 207 |
})
|
| 208 |
+
return _parse_quiz({"output": response.content})
|
|
|
|
|
|
|
|
|
|
| 209 |
except Exception as e:
|
| 210 |
+
logger.warning(f"Primary Web Quiz model failed: {e}. Falling back to gemini-3.1-flash-lite.")
|
| 211 |
+
try:
|
| 212 |
+
fallback_llm = get_quiz_fallback_llm()
|
| 213 |
+
chain = prompt | fallback_llm
|
| 214 |
+
response = chain.invoke({
|
| 215 |
+
"topic": topic_title,
|
| 216 |
+
"context": combined_context,
|
| 217 |
+
"difficulty": difficulty,
|
| 218 |
+
"mcq_count": mcq_count,
|
| 219 |
+
"tf_count": tf_count,
|
| 220 |
+
"source_type": "Web Search",
|
| 221 |
+
"agent_scratchpad": "",
|
| 222 |
+
})
|
| 223 |
+
return _parse_quiz({"output": response.content})
|
| 224 |
+
except Exception as fallback_err:
|
| 225 |
+
logger.error(f"Fallback Web Quiz failed: {fallback_err}", exc_info=True)
|
| 226 |
+
raise fallback_err
|
| 227 |
|
| 228 |
|
| 229 |
def _parse_quiz(response):
|
rag/constants.py
CHANGED
|
@@ -6,15 +6,15 @@ WARMUP_INTERVAL_S = 300
|
|
| 6 |
|
| 7 |
# Web search configuration β Wiki + DDG for topics, DDG only for PDF/URL materials.
|
| 8 |
WIKI_TOP_K_RESULTS = 1 # Number of top Wikipedia articles retrieved
|
| 9 |
-
WIKI_DOC_CONTENT_CHARS_MAX =
|
| 10 |
|
| 11 |
# DuckDuckGO Search
|
| 12 |
DUCKDUCKGO_NUM_RESULTS = 3 # Number of DDG snippet results returned per search
|
| 13 |
-
DUCKDUCKGO_DOC_CONTENT_CHARS_MAX =
|
| 14 |
|
| 15 |
# RAG & Memory Configuration
|
| 16 |
-
MEMORY_WINDOW_SIZE =
|
| 17 |
-
TOP_K_CHUNKS =
|
| 18 |
|
| 19 |
RAG_PROMPT_TEMPLATE_BASE = """\
|
| 20 |
<role>
|
|
|
|
| 6 |
|
| 7 |
# Web search configuration β Wiki + DDG for topics, DDG only for PDF/URL materials.
|
| 8 |
WIKI_TOP_K_RESULTS = 1 # Number of top Wikipedia articles retrieved
|
| 9 |
+
WIKI_DOC_CONTENT_CHARS_MAX = 1200 # Max chars from Wikipedia result
|
| 10 |
|
| 11 |
# DuckDuckGO Search
|
| 12 |
DUCKDUCKGO_NUM_RESULTS = 3 # Number of DDG snippet results returned per search
|
| 13 |
+
DUCKDUCKGO_DOC_CONTENT_CHARS_MAX = 1200 # Max chars kept from combined DDG result block
|
| 14 |
|
| 15 |
# RAG & Memory Configuration
|
| 16 |
+
MEMORY_WINDOW_SIZE = 5 # Number of previous conversation turns preserved in memory window
|
| 17 |
+
TOP_K_CHUNKS = 4 # Number of top relevant material chunks retrieved for context
|
| 18 |
|
| 19 |
RAG_PROMPT_TEMPLATE_BASE = """\
|
| 20 |
<role>
|
rag/rag.py
CHANGED
|
@@ -13,6 +13,7 @@ from langchain.memory import ConversationBufferMemory, ConversationBufferWindowM
|
|
| 13 |
from langchain_core.retrievers import BaseRetriever
|
| 14 |
from langchain_core.documents import Document
|
| 15 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
|
|
| 16 |
|
| 17 |
from src.config import settings
|
| 18 |
from src.database import get_supabase
|
|
@@ -136,35 +137,85 @@ def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
|
|
| 136 |
# ββ LLM ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
|
| 138 |
def get_llm():
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
temperature=0.3,
|
| 146 |
-
|
| 147 |
timeout=120,
|
| 148 |
)
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
def get_quiz_llm():
|
| 152 |
-
"""
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
|
| 158 |
-
logger.info(f"Initializing Quiz LLM with model: {settings.model_name}")
|
| 159 |
return ChatGoogleGenerativeAI(
|
| 160 |
-
model=
|
| 161 |
-
api_key=
|
| 162 |
temperature=0.3,
|
| 163 |
max_output_tokens=12000,
|
| 164 |
timeout=300,
|
| 165 |
)
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def _clean_llm_response(content) -> str:
|
| 169 |
if isinstance(content, str):
|
| 170 |
return content
|
|
@@ -182,32 +233,6 @@ def _clean_llm_response(content) -> str:
|
|
| 182 |
return str(content)
|
| 183 |
|
| 184 |
|
| 185 |
-
def get_gemma_31b_llm():
|
| 186 |
-
if not os.environ.get("GEMINI_API_KEY"):
|
| 187 |
-
raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
|
| 188 |
-
logger.info("Initializing primary LLM with model: gemma-4-31b-it")
|
| 189 |
-
return ChatGoogleGenerativeAI(
|
| 190 |
-
model="gemma-4-31b-it",
|
| 191 |
-
api_key=settings.gemini_api_key,
|
| 192 |
-
temperature=0.3,
|
| 193 |
-
max_output_tokens=2500,
|
| 194 |
-
timeout=120,
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def get_gemma_26b_llm():
|
| 199 |
-
if not os.environ.get("GEMINI_API_KEY"):
|
| 200 |
-
raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
|
| 201 |
-
logger.info("Initializing fallback LLM with model: gemma-4-26b-a4b-it")
|
| 202 |
-
return ChatGoogleGenerativeAI(
|
| 203 |
-
model="gemma-4-26b-a4b-it",
|
| 204 |
-
api_key=settings.gemini_api_key,
|
| 205 |
-
temperature=0.3,
|
| 206 |
-
max_output_tokens=2500,
|
| 207 |
-
timeout=120,
|
| 208 |
-
)
|
| 209 |
-
|
| 210 |
-
|
| 211 |
# ββ Web Search Helpers ββββββββββββββββββββββββββββββββ
|
| 212 |
|
| 213 |
def direct_ddg_search(query: str) -> str:
|
|
@@ -386,7 +411,7 @@ def rag_answer(
|
|
| 386 |
return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
|
| 387 |
|
| 388 |
try:
|
| 389 |
-
primary_llm =
|
| 390 |
chain = prompt | primary_llm
|
| 391 |
|
| 392 |
memory_vars = memory.load_memory_variables({"input": query})
|
|
@@ -405,9 +430,9 @@ def rag_answer(
|
|
| 405 |
memory.save_context({"input": query}, {"output": answer})
|
| 406 |
return answer, memory
|
| 407 |
except Exception as e:
|
| 408 |
-
logger.warning(f"
|
| 409 |
try:
|
| 410 |
-
fallback_llm =
|
| 411 |
chain = prompt | fallback_llm
|
| 412 |
memory_vars = memory.load_memory_variables({"input": query})
|
| 413 |
chat_history = memory_vars.get("chat_history", [])
|
|
@@ -424,7 +449,7 @@ def rag_answer(
|
|
| 424 |
memory.save_context({"input": query}, {"output": answer})
|
| 425 |
return answer, memory
|
| 426 |
except Exception as fallback_err:
|
| 427 |
-
logger.error(f"Fallback
|
| 428 |
raise fallback_err
|
| 429 |
|
| 430 |
def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
|
|
@@ -440,17 +465,17 @@ def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
|
|
| 440 |
)
|
| 441 |
|
| 442 |
try:
|
| 443 |
-
primary_llm =
|
| 444 |
chain = prompt | primary_llm
|
| 445 |
response = chain.invoke({"query": query})
|
| 446 |
except Exception as e:
|
| 447 |
-
logger.warning(f"
|
| 448 |
try:
|
| 449 |
-
fallback_llm =
|
| 450 |
chain = prompt | fallback_llm
|
| 451 |
response = chain.invoke({"query": query})
|
| 452 |
except Exception as fallback_err:
|
| 453 |
-
logger.error(f"Fallback
|
| 454 |
raise fallback_err
|
| 455 |
|
| 456 |
title = _clean_llm_response(response.content).strip().strip('"').strip("'")
|
|
|
|
| 13 |
from langchain_core.retrievers import BaseRetriever
|
| 14 |
from langchain_core.documents import Document
|
| 15 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 16 |
+
from langchain_groq import ChatGroq
|
| 17 |
|
| 18 |
from src.config import settings
|
| 19 |
from src.database import get_supabase
|
|
|
|
| 137 |
# ββ LLM ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
|
| 139 |
def get_llm():
|
| 140 |
+
"""RAG Chatbot LLM β strictly uses Groq llama-3.1-8b-instant."""
|
| 141 |
+
groq_key = os.environ.get("GROQ_API_KEY")
|
| 142 |
+
if not groq_key:
|
| 143 |
+
raise ValueError("GROQ_API_KEY is not configured in config.env. Required for Llama 3.1 RAG chatbot.")
|
| 144 |
+
logger.info("Initializing RAG Chatbot LLM with Groq model: llama-3.1-8b-instant")
|
| 145 |
+
return ChatGroq(
|
| 146 |
+
model="llama-3.1-8b-instant",
|
| 147 |
+
api_key=groq_key,
|
| 148 |
temperature=0.3,
|
| 149 |
+
max_tokens=2500,
|
| 150 |
timeout=120,
|
| 151 |
)
|
| 152 |
|
| 153 |
|
| 154 |
+
def get_summary_llm():
|
| 155 |
+
"""Primary Summary Generator LLM using gemini-3.5-flash-lite."""
|
| 156 |
+
gemini_key = os.environ.get("GEMINI_API_KEY")
|
| 157 |
+
if not gemini_key:
|
| 158 |
+
raise ValueError("GEMINI_API_KEY not found in config.env.")
|
| 159 |
+
logger.info("Initializing Summary LLM with model: gemini-3.5-flash-lite")
|
| 160 |
+
return ChatGoogleGenerativeAI(
|
| 161 |
+
model="gemini-3.5-flash-lite",
|
| 162 |
+
api_key=gemini_key,
|
| 163 |
+
temperature=0.3,
|
| 164 |
+
max_output_tokens=4000,
|
| 165 |
+
timeout=180,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_summary_fallback_llm():
|
| 170 |
+
"""Fallback Summary Generator LLM using gemini-3.1-flash-lite."""
|
| 171 |
+
gemini_key = os.environ.get("GEMINI_API_KEY")
|
| 172 |
+
if not gemini_key:
|
| 173 |
+
raise ValueError("GEMINI_API_KEY not found in config.env.")
|
| 174 |
+
logger.info("Initializing Fallback Summary LLM with model: gemini-3.1-flash-lite")
|
| 175 |
+
return ChatGoogleGenerativeAI(
|
| 176 |
+
model="gemini-3.1-flash-lite",
|
| 177 |
+
api_key=gemini_key,
|
| 178 |
+
temperature=0.3,
|
| 179 |
+
max_output_tokens=4000,
|
| 180 |
+
timeout=180,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
def get_quiz_llm():
|
| 185 |
+
"""Primary Quiz Generator LLM using gemini-3.5-flash-lite."""
|
| 186 |
+
gemini_key = os.environ.get("GEMINI_API_KEY")
|
| 187 |
+
if not gemini_key:
|
| 188 |
+
raise ValueError("GEMINI_API_KEY not found in config.env.")
|
| 189 |
+
logger.info("Initializing Quiz LLM with model: gemini-3.5-flash-lite")
|
|
|
|
|
|
|
| 190 |
return ChatGoogleGenerativeAI(
|
| 191 |
+
model="gemini-3.5-flash-lite",
|
| 192 |
+
api_key=gemini_key,
|
| 193 |
temperature=0.3,
|
| 194 |
max_output_tokens=12000,
|
| 195 |
timeout=300,
|
| 196 |
)
|
| 197 |
|
| 198 |
|
| 199 |
+
def get_quiz_fallback_llm():
|
| 200 |
+
"""Fallback Quiz Generator LLM using gemini-3.1-flash-lite."""
|
| 201 |
+
gemini_key = os.environ.get("GEMINI_API_KEY")
|
| 202 |
+
if not gemini_key:
|
| 203 |
+
raise ValueError("GEMINI_API_KEY not found in config.env.")
|
| 204 |
+
logger.info("Initializing Fallback Quiz LLM with model: gemini-3.1-flash-lite")
|
| 205 |
+
return ChatGoogleGenerativeAI(
|
| 206 |
+
model="gemini-3.1-flash-lite",
|
| 207 |
+
api_key=gemini_key,
|
| 208 |
+
temperature=0.3,
|
| 209 |
+
max_output_tokens=12000,
|
| 210 |
+
timeout=300,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def get_fallback_llm():
|
| 215 |
+
"""Fallback LLM using Gemini 3.1 Flash Lite."""
|
| 216 |
+
return get_summary_fallback_llm()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
def _clean_llm_response(content) -> str:
|
| 220 |
if isinstance(content, str):
|
| 221 |
return content
|
|
|
|
| 233 |
return str(content)
|
| 234 |
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
# ββ Web Search Helpers ββββββββββββββββββββββββββββββββ
|
| 237 |
|
| 238 |
def direct_ddg_search(query: str) -> str:
|
|
|
|
| 411 |
return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
|
| 412 |
|
| 413 |
try:
|
| 414 |
+
primary_llm = get_llm()
|
| 415 |
chain = prompt | primary_llm
|
| 416 |
|
| 417 |
memory_vars = memory.load_memory_variables({"input": query})
|
|
|
|
| 430 |
memory.save_context({"input": query}, {"output": answer})
|
| 431 |
return answer, memory
|
| 432 |
except Exception as e:
|
| 433 |
+
logger.warning(f"Primary LLM call failed or rate-limited: {e}. Falling back to secondary LLM.")
|
| 434 |
try:
|
| 435 |
+
fallback_llm = get_fallback_llm()
|
| 436 |
chain = prompt | fallback_llm
|
| 437 |
memory_vars = memory.load_memory_variables({"input": query})
|
| 438 |
chat_history = memory_vars.get("chat_history", [])
|
|
|
|
| 449 |
memory.save_context({"input": query}, {"output": answer})
|
| 450 |
return answer, memory
|
| 451 |
except Exception as fallback_err:
|
| 452 |
+
logger.error(f"Fallback LLM call also failed: {fallback_err}")
|
| 453 |
raise fallback_err
|
| 454 |
|
| 455 |
def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
|
|
|
|
| 465 |
)
|
| 466 |
|
| 467 |
try:
|
| 468 |
+
primary_llm = get_llm()
|
| 469 |
chain = prompt | primary_llm
|
| 470 |
response = chain.invoke({"query": query})
|
| 471 |
except Exception as e:
|
| 472 |
+
logger.warning(f"Primary LLM call failed in extract_chat_title: {e}. Falling back to secondary LLM.")
|
| 473 |
try:
|
| 474 |
+
fallback_llm = get_fallback_llm()
|
| 475 |
chain = prompt | fallback_llm
|
| 476 |
response = chain.invoke({"query": query})
|
| 477 |
except Exception as fallback_err:
|
| 478 |
+
logger.error(f"Fallback LLM call also failed in extract_chat_title: {fallback_err}")
|
| 479 |
raise fallback_err
|
| 480 |
|
| 481 |
title = _clean_llm_response(response.content).strip().strip('"').strip("'")
|
requirements.txt
CHANGED
|
@@ -11,6 +11,8 @@ streamlit==1.45.0
|
|
| 11 |
langchain==0.3.25
|
| 12 |
langchain-community==0.3.4
|
| 13 |
langchain-google-genai
|
|
|
|
|
|
|
| 14 |
langchain-openai
|
| 15 |
langchain-huggingface
|
| 16 |
langchain-text-splitters
|
|
|
|
| 11 |
langchain==0.3.25
|
| 12 |
langchain-community==0.3.4
|
| 13 |
langchain-google-genai
|
| 14 |
+
langchain-groq
|
| 15 |
+
groq
|
| 16 |
langchain-openai
|
| 17 |
langchain-huggingface
|
| 18 |
langchain-text-splitters
|
store.py
CHANGED
|
@@ -294,9 +294,10 @@ def delete_material(material_id: str):
|
|
| 294 |
# ββ Material Chunks ββββββββββββββββββββββββββββββββββββ
|
| 295 |
|
| 296 |
def save_chunks(material_id: str, chunks: list[str]) -> list[str]:
|
|
|
|
| 297 |
records = [
|
| 298 |
{"material_id": material_id, "chunk_index": i, "content": c}
|
| 299 |
-
for i, c in enumerate(
|
| 300 |
]
|
| 301 |
result = _robust_execute(_table_supabase("material_chunks").insert(records))
|
| 302 |
return [r["id"] for r in result.data]
|
|
|
|
| 294 |
# ββ Material Chunks ββββββββββββββββββββββββββββββββββββ
|
| 295 |
|
| 296 |
def save_chunks(material_id: str, chunks: list[str]) -> list[str]:
|
| 297 |
+
cleaned_chunks = [c.replace("\x00", "").replace("\u0000", "") for c in chunks if c]
|
| 298 |
records = [
|
| 299 |
{"material_id": material_id, "chunk_index": i, "content": c}
|
| 300 |
+
for i, c in enumerate(cleaned_chunks)
|
| 301 |
]
|
| 302 |
result = _robust_execute(_table_supabase("material_chunks").insert(records))
|
| 303 |
return [r["id"] for r in result.data]
|
summary_generator/summary.py
CHANGED
|
@@ -2,7 +2,7 @@ import re
|
|
| 2 |
import logging
|
| 3 |
import concurrent.futures
|
| 4 |
from langchain_community.utilities import WikipediaAPIWrapper, DuckDuckGoSearchAPIWrapper
|
| 5 |
-
from src.rag.rag import
|
| 6 |
from .constants import (
|
| 7 |
SUMMARIZER_PROMPT_TEMPLATE,
|
| 8 |
WEB_SUMMARIZER_PROMPT_TEMPLATE,
|
|
@@ -51,21 +51,23 @@ def _truncate_text(text: str, max_chars: int = MAX_INPUT_CHARS) -> str:
|
|
| 51 |
def summarizer(text: str) -> str:
|
| 52 |
logger.info(f"Summarizer started for text of length {len(text)}")
|
| 53 |
text = _truncate_text(text)
|
|
|
|
|
|
|
| 54 |
try:
|
| 55 |
-
|
| 56 |
-
llm = get_llm()
|
| 57 |
-
# Modern LCEL syntax
|
| 58 |
chain = prompt | llm
|
| 59 |
response = chain.invoke({"input": text})
|
| 60 |
-
|
| 61 |
-
raw_content = response.content
|
| 62 |
-
logger.info(f"Summarizer received response of length {len(raw_content)}")
|
| 63 |
-
logger.debug(f"Raw summary response: {raw_content[:500]}...")
|
| 64 |
-
|
| 65 |
-
return clean_summary(raw_content)
|
| 66 |
except Exception as e:
|
| 67 |
-
logger.
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def fetch_web_content(topic: str) -> str:
|
|
@@ -133,12 +135,17 @@ def web_summarizer(topic: str, raw_content: str | None = None) -> str:
|
|
| 133 |
combined = _truncate_text(combined)
|
| 134 |
|
| 135 |
try:
|
| 136 |
-
llm =
|
| 137 |
chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | llm
|
| 138 |
response = chain.invoke({"topic": topic, "input": combined})
|
| 139 |
-
|
| 140 |
-
logger.info(f"Web summarizer received response of length {len(raw_resp)}")
|
| 141 |
-
return clean_summary(raw_resp)
|
| 142 |
except Exception as e:
|
| 143 |
-
logger.
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import logging
|
| 3 |
import concurrent.futures
|
| 4 |
from langchain_community.utilities import WikipediaAPIWrapper, DuckDuckGoSearchAPIWrapper
|
| 5 |
+
from src.rag.rag import get_summary_llm, get_summary_fallback_llm
|
| 6 |
from .constants import (
|
| 7 |
SUMMARIZER_PROMPT_TEMPLATE,
|
| 8 |
WEB_SUMMARIZER_PROMPT_TEMPLATE,
|
|
|
|
| 51 |
def summarizer(text: str) -> str:
|
| 52 |
logger.info(f"Summarizer started for text of length {len(text)}")
|
| 53 |
text = _truncate_text(text)
|
| 54 |
+
prompt = summarizer_prompt()
|
| 55 |
+
|
| 56 |
try:
|
| 57 |
+
llm = get_summary_llm()
|
|
|
|
|
|
|
| 58 |
chain = prompt | llm
|
| 59 |
response = chain.invoke({"input": text})
|
| 60 |
+
return clean_summary(response.content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
except Exception as e:
|
| 62 |
+
logger.warning(f"Primary summary model (gemini-3.5-flash-lite) failed: {e}. Falling back to gemini-3.1-flash-lite.")
|
| 63 |
+
try:
|
| 64 |
+
fallback_llm = get_summary_fallback_llm()
|
| 65 |
+
chain = prompt | fallback_llm
|
| 66 |
+
response = chain.invoke({"input": text})
|
| 67 |
+
return clean_summary(response.content)
|
| 68 |
+
except Exception as fallback_err:
|
| 69 |
+
logger.error(f"Fallback summary generation failed: {fallback_err}", exc_info=True)
|
| 70 |
+
raise fallback_err
|
| 71 |
|
| 72 |
|
| 73 |
def fetch_web_content(topic: str) -> str:
|
|
|
|
| 135 |
combined = _truncate_text(combined)
|
| 136 |
|
| 137 |
try:
|
| 138 |
+
llm = get_summary_llm()
|
| 139 |
chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | llm
|
| 140 |
response = chain.invoke({"topic": topic, "input": combined})
|
| 141 |
+
return clean_summary(response.content)
|
|
|
|
|
|
|
| 142 |
except Exception as e:
|
| 143 |
+
logger.warning(f"Primary web summarizer model (gemini-3.5-flash-lite) failed: {e}. Falling back to gemini-3.1-flash-lite.")
|
| 144 |
+
try:
|
| 145 |
+
fallback_llm = get_summary_fallback_llm()
|
| 146 |
+
chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | fallback_llm
|
| 147 |
+
response = chain.invoke({"topic": topic, "input": combined})
|
| 148 |
+
return clean_summary(response.content)
|
| 149 |
+
except Exception as fallback_err:
|
| 150 |
+
logger.error(f"Fallback web summarizer failed: {fallback_err}", exc_info=True)
|
| 151 |
+
raise fallback_err
|