Hamdy005 commited on
Commit
25c8a13
Β·
1 Parent(s): d052f4f

feat: implement LLM fallback for quiz generation and enhance OAuth error handling

Browse files
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
- # lifespan=lifespan,
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
- text += page.extract_text()
11
- return text
 
 
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
- return splitter.split_text(text)
 
19
 
20
 
21
  def scrap_website(url: str) -> str:
22
  loader = UnstructuredURLLoader(urls=[url], ssl_verify=True)
23
  data = loader.load()
24
- return data[0].page_content
 
 
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.error(f"Summary Quiz failed: {str(e)}", exc_info=True)
83
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.error(f"Contextual Quiz failed: {str(e)}", exc_info=True)
123
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.error(f"Web Quiz failed: {str(e)}", exc_info=True)
186
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 1000 # 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 = 1500 # Max chars kept from combined DDG result block
14
 
15
  # RAG & Memory Configuration
16
- MEMORY_WINDOW_SIZE = 8 # Number of previous conversation turns preserved in memory window
17
- TOP_K_CHUNKS = 3 # Number of top relevant material chunks retrieved for context
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
- if not os.environ.get("GEMINI_API_KEY"):
140
- raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
141
- logger.info(f"Initializing LLM with model: {settings.model_name}")
142
- return ChatGoogleGenerativeAI(
143
- model=settings.model_name,
144
- api_key=settings.gemini_api_key,
 
 
145
  temperature=0.3,
146
- max_output_tokens=2500,
147
  timeout=120,
148
  )
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  def get_quiz_llm():
152
- """Dedicated LLM instance for quiz generation with a high output token budget.
153
- Large quizzes (40 MCQs + 20 T/F) can produce 8000-12000 tokens of JSON,
154
- so we cannot reuse the chat LLM which is capped at 2000 tokens.
155
- """
156
- if not os.environ.get("GEMINI_API_KEY"):
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=settings.model_name,
161
- api_key=settings.gemini_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 = get_gemma_26b_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"Gemma 4 26B API call failed or rate-limited: {e}. Falling back to gemma-4-31b-it immediately.")
409
  try:
410
- fallback_llm = get_gemma_31b_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 Gemma 4 31B LLM call also failed: {fallback_err}")
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 = get_gemma_26b_llm()
444
  chain = prompt | primary_llm
445
  response = chain.invoke({"query": query})
446
  except Exception as e:
447
- logger.warning(f"Gemma 4 26B API call failed or rate-limited in extract_chat_title: {e}. Falling back to gemma-4-31b-it immediately.")
448
  try:
449
- fallback_llm = get_gemma_31b_llm()
450
  chain = prompt | fallback_llm
451
  response = chain.invoke({"query": query})
452
  except Exception as fallback_err:
453
- logger.error(f"Fallback Gemma 4 31B LLM call also failed in extract_chat_title: {fallback_err}")
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(chunks)
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 get_llm
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
- prompt = summarizer_prompt()
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.error(f"Summarizer failed: {str(e)}", exc_info=True)
68
- raise
 
 
 
 
 
 
 
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 = get_llm()
137
  chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | llm
138
  response = chain.invoke({"topic": topic, "input": combined})
139
- raw_resp = response.content
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.error(f"Web summarizer failed: {str(e)}", exc_info=True)
144
- raise
 
 
 
 
 
 
 
 
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