Hamdy005 commited on
Commit
ca98c9f
·
1 Parent(s): bffc068

feat: improve quiz generation logic and RAG tool robustness with enhanced prompt engineering and search error handling

Browse files
Files changed (3) hide show
  1. quiz_generator/routes.py +18 -6
  2. rag/rag.py +120 -45
  3. rag/routes.py +14 -4
quiz_generator/routes.py CHANGED
@@ -1,4 +1,5 @@
1
  import asyncio
 
2
  from fastapi import APIRouter, HTTPException, Depends
3
  from pydantic import BaseModel
4
  from typing import Optional
@@ -8,6 +9,8 @@ from src.store import get_material, get_chunks, get_summary, save_quiz, get_quiz
8
  from src.dependencies import get_current_user_id, get_current_user
9
  from src.config import settings
10
 
 
 
11
  router = APIRouter(prefix="/api/quiz", tags=["Quiz"])
12
 
13
 
@@ -52,11 +55,18 @@ async def generate_quiz(
52
 
53
  try:
54
  quiz = None
55
- material_id = body.material_id if body.source_type in ("pdf", "url") else None
 
 
 
 
 
 
 
 
 
 
56
 
57
- if body.source_type == "web":
58
- if not body.topic:
59
- raise HTTPException(400, "Topic is required for web-based quiz")
60
  loop = asyncio.get_event_loop()
61
  quiz = await loop.run_in_executor(
62
  None,
@@ -64,7 +74,7 @@ async def generate_quiz(
64
  difficulty=body.difficulty,
65
  mcq_count=body.mcq_count,
66
  tf_count=body.tf_count,
67
- topic_title=body.topic,
68
  )
69
  )
70
 
@@ -96,7 +106,7 @@ async def generate_quiz(
96
  saved = save_quiz(
97
  user_id=user_id,
98
  material_id=material_id,
99
- source_type=body.source_type,
100
  difficulty=body.difficulty,
101
  mcq_count=body.mcq_count,
102
  tf_count=body.tf_count,
@@ -106,8 +116,10 @@ async def generate_quiz(
106
 
107
  return QuizResponse(quiz=quiz, quiz_id=saved["id"])
108
  except ValueError as e:
 
109
  raise HTTPException(400, str(e))
110
  except Exception as e:
 
111
  raise HTTPException(500, f"Quiz generation failed: {e}")
112
 
113
 
 
1
  import asyncio
2
+ import logging
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from pydantic import BaseModel
5
  from typing import Optional
 
9
  from src.dependencies import get_current_user_id, get_current_user
10
  from src.config import settings
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
  router = APIRouter(prefix="/api/quiz", tags=["Quiz"])
15
 
16
 
 
55
 
56
  try:
57
  quiz = None
58
+ material_id = body.material_id
59
+
60
+ if body.source_type in ("web", "topic"):
61
+ topic_title = body.topic
62
+ if not topic_title and body.material_id:
63
+ mat = get_material(body.material_id)
64
+ if mat:
65
+ topic_title = mat.get("title")
66
+
67
+ if not topic_title:
68
+ raise HTTPException(400, "Topic title or valid material_id is required for web-based quiz")
69
 
 
 
 
70
  loop = asyncio.get_event_loop()
71
  quiz = await loop.run_in_executor(
72
  None,
 
74
  difficulty=body.difficulty,
75
  mcq_count=body.mcq_count,
76
  tf_count=body.tf_count,
77
+ topic_title=topic_title,
78
  )
79
  )
80
 
 
106
  saved = save_quiz(
107
  user_id=user_id,
108
  material_id=material_id,
109
+ source_type="web" if body.source_type == "topic" else body.source_type,
110
  difficulty=body.difficulty,
111
  mcq_count=body.mcq_count,
112
  tf_count=body.tf_count,
 
116
 
117
  return QuizResponse(quiz=quiz, quiz_id=saved["id"])
118
  except ValueError as e:
119
+ logger.warning(f"Validation error in generate_quiz: {str(e)}")
120
  raise HTTPException(400, str(e))
121
  except Exception as e:
122
+ logger.error(f"Quiz generation failed: {str(e)}", exc_info=True)
123
  raise HTTPException(500, f"Quiz generation failed: {e}")
124
 
125
 
rag/rag.py CHANGED
@@ -4,6 +4,8 @@ import uuid
4
  import logging
5
  from functools import lru_cache
6
  from typing import Optional
 
 
7
 
8
  from langchain_huggingface import HuggingFaceEmbeddings
9
  from langchain.prompts import PromptTemplate
@@ -18,7 +20,7 @@ from langchain_openai import ChatOpenAI
18
 
19
  from src.config import settings
20
  from src.database import get_supabase
21
- from src.store import get_chunks
22
 
23
  logger = logging.getLogger(__name__)
24
 
@@ -144,21 +146,74 @@ def get_groq_llm():
144
 
145
  # ── Web Search Tools ───────────────────────────────────
146
 
147
- def web_search_tools(has_material: bool = False):
148
- top_k = 1 if has_material else 2
149
- chars_max = 1500 if has_material else 4000
 
 
 
150
 
151
- wikipedia = WikipediaQueryRun(
152
- api_wrapper=WikipediaAPIWrapper(top_k_results=top_k, doc_content_chars_max=chars_max)
153
- )
154
- arxiv = ArxivQueryRun(
155
- api_wrapper=ArxivAPIWrapper(top_k_results=top_k, doc_content_chars_max=chars_max)
156
- )
157
- duck = DuckDuckGoSearchResults()
158
- return [wikipedia, arxiv, duck]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
 
161
- # ── Supabase Retriever (replaces FAISS) ────────────────
162
 
163
  class SupabaseRetriever(BaseRetriever):
164
  material_id: str
@@ -177,46 +232,47 @@ class SupabaseRetriever(BaseRetriever):
177
 
178
  # ── RAG Prompt ─────────────────────────────────────────
179
 
180
- def _rag_prompt(has_tools: bool = True):
181
- tools_section = """
182
- You have access to these tools:
183
- - **Wikipedia Retriever** for general knowledge and conceptual explanations,
184
- - **Arxiv Retriever** for academic and scientific research information,
185
- - **DuckDuckGo Retriever** for the latest web-based insights,
186
- - **Knowledge Retriever:** for local learning materials (vector embeddings, summaries, raw text chunks).
187
- """ if has_tools else ""
 
 
 
 
 
 
188
 
189
  return PromptTemplate(
190
  input_variables=["chat_history", "input", "agent_scratchpad", "context"],
191
  template=f"""
192
- You are a helpful AI study assistant. Your goal is to provide accurate, well-reasoned answers.
193
 
194
- You have access to the following context to help you answer:
195
-
196
- Context:
197
  {{context}}
198
  {tools_section}
 
199
  ## Instructions:
200
  - Use the available context and tools to answer the user's question as thoroughly as possible.
201
- - The "Context" section contains direct excerpts and information from the user's learning material. You MUST use this to answer questions, even if the "Chat History" is empty.
202
- - Never claim you don't have information about the lecture or material just because the conversation has just started; always check the "Context" first.
203
- - If you find relevant information in the context, synthesize it into a clear, well-structured answer.
204
  - If the context partially answers the question, explain what you know and note any limitations.
205
  - If the context and tools don't contain enough information, use your own knowledge to provide a helpful response and mention that it's based on general knowledge.
206
  - Always provide educational value - explain concepts clearly.
207
-
208
- ## FORMATTING RULES:
209
- - Do NOT use markdown tables or pipe characters (|)
210
- - Do NOT use separator lines (---, ===)
211
- - Use ### for Section Headings (e.g. ### Answer:)
 
 
 
212
  - Use **Text** for important keywords, topics, or terms you want to highlight
213
- - Use plain text with clear section labels followed by a colon (e.g. "Answer:", "Key Takeaway:")
214
  - Use numbered lists or bullet points (with a dash -) instead of tables
215
 
216
- ## Response Format:
217
- - Answer: Provide a detailed, structured explanation.
218
- - Key Takeaway: Conclude with a short, relevant summary point.
219
-
220
  ---
221
  ### Chat History:
222
  {{chat_history}}
@@ -244,19 +300,28 @@ def rag_answer(
244
  input_key="input", memory_key="chat_history", return_messages=True, k=5
245
  )
246
 
247
- # User requested: remove tool usage when material is uploaded, keep when not
 
248
  if material_id:
 
 
 
 
 
249
  tools = []
250
  else:
251
  tools = web_search_tools(has_material=False)
252
 
253
- prompt = _rag_prompt(has_tools=len(tools) > 0)
254
  llm = get_groq_llm()
255
 
256
  context_parts = []
257
  has_chunks = False
258
 
259
- if material_id:
 
 
 
 
260
  results = similarity_search(query, material_id, k=5)
261
  if results:
262
  has_chunks = True
@@ -268,7 +333,7 @@ def rag_answer(
268
  context_parts.append(f"Material Summary (No specific excerpts found for your query):\n{summaries}")
269
 
270
  # Fallback: If NO chunks matched AND NO summary was generated, pass start and end chunks
271
- if not has_chunks and not summaries and material_id:
272
  all_chunks = get_chunks(material_id)
273
  if all_chunks:
274
  # Take first 3 and last 2 chunks
@@ -279,7 +344,11 @@ def rag_answer(
279
  sampled_text = "\n---\n".join(c["content"] for c in sampled)
280
  context_parts.append(f"Material Sample (No summary found; showing start and end of material):\n{sampled_text}")
281
 
282
- context_str = "\n\n".join(context_parts) if context_parts else ""
 
 
 
 
283
 
284
  if tools:
285
  agent = create_openai_tools_agent(llm, tools, prompt)
@@ -290,6 +359,7 @@ def rag_answer(
290
  verbose=False,
291
  return_intermediate_steps=False,
292
  handle_parsing_errors=True,
 
293
  )
294
  response = executor.invoke({"input": query, "context": context_str})
295
  return response["output"], memory
@@ -313,11 +383,16 @@ def rag_answer(
313
  memory.save_context({"input": query}, {"output": answer})
314
  return answer, memory
315
 
316
- def extract_chat_title(query: str) -> str:
317
  llm = get_groq_llm()
 
 
 
 
 
318
  prompt = PromptTemplate(
319
  input_variables=["query"],
320
- template="Generate a very short, concise title (3-5 words max) for a chat session that starts with this user query: '{query}'. Do not use quotes or prefixes like 'Title:', just the title itself."
321
  )
322
  chain = prompt | llm
323
  response = chain.invoke({"query": query})
 
4
  import logging
5
  from functools import lru_cache
6
  from typing import Optional
7
+ from pydantic import BaseModel, Field
8
+ from langchain_core.tools import Tool
9
 
10
  from langchain_huggingface import HuggingFaceEmbeddings
11
  from langchain.prompts import PromptTemplate
 
20
 
21
  from src.config import settings
22
  from src.database import get_supabase
23
+ from src.store import get_chunks, get_material
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
146
 
147
  # ── Web Search Tools ───────────────────────────────────
148
 
149
+ class SearchInput(BaseModel):
150
+ query: str = Field(description="The search query or topic to look up")
151
+
152
+ def web_search_tools(has_material: bool = False, top_k: int = 2, chars_max: int = 1500):
153
+
154
+ tools = []
155
 
156
+ # Target ~12,000 characters total for searches combined to leave room for prompt + output.
157
+ wiki_k = 1; wiki_chars = 4000
158
+ arxiv_k = 1; arxiv_chars = 4000
159
+ duck_chars = 4000
160
+
161
+ try:
162
+ wiki_api = WikipediaAPIWrapper(top_k_results=wiki_k, doc_content_chars_max=wiki_chars)
163
+ def safe_wiki_run(query: str) -> str:
164
+ try: return wiki_api.run(query)[:wiki_k * wiki_chars]
165
+ except Exception as e: return f"Wikipedia search failed: {e}. Try another tool."
166
+
167
+ wikipedia = Tool(
168
+ name="wikipedia",
169
+ description="A wrapper around Wikipedia. Useful for answering general questions about people, places, facts, or historical events. Input should be a search query.",
170
+ func=safe_wiki_run,
171
+ args_schema=SearchInput
172
+ )
173
+ tools.append(wikipedia)
174
+ except Exception as e:
175
+ logger.warning(f"Skipping Wikipedia Search: {e}")
176
+ # Wikipedia skipped -> allocate its budget to Arxiv
177
+ arxiv_k = 2; arxiv_chars = 4000
178
+ duck_chars = 4000
179
+
180
+ try:
181
+ arxiv_api = ArxivAPIWrapper(top_k_results=arxiv_k, doc_content_chars_max=arxiv_chars)
182
+ def safe_arxiv_run(query: str) -> str:
183
+ try: return arxiv_api.run(query)[:arxiv_k * arxiv_chars]
184
+ except Exception as e: return f"Arxiv search failed: {e}. Try another tool."
185
+
186
+ arxiv = Tool(
187
+ name="arxiv",
188
+ description="A wrapper around Arxiv.org. Useful for answering questions from scientific articles in Physics, Math, Computer Science, Biology, etc. Input should be a search query.",
189
+ func=safe_arxiv_run,
190
+ args_schema=SearchInput
191
+ )
192
+ tools.append(arxiv)
193
+ except Exception as e:
194
+ logger.warning(f"Skipping Arxiv Search: {e}")
195
+ # Arxiv skipped -> allocate its budget to DuckDuckGo
196
+ duck_chars += (arxiv_k * arxiv_chars)
197
+
198
+ try:
199
+ duck_api = DuckDuckGoSearchResults()
200
+ def safe_duck_run(query: str) -> str:
201
+ try: return duck_api.run(query)[:duck_chars]
202
+ except Exception as e: return f"DuckDuckGo search failed: {e}."
203
+
204
+ duck = Tool(
205
+ name="duckduckgo",
206
+ description="A wrapper around DuckDuckGo Search. Useful for answering questions about current events or latest web insights. Input should be a search query.",
207
+ func=safe_duck_run,
208
+ args_schema=SearchInput
209
+ )
210
+ tools.append(duck)
211
+ except Exception as e:
212
+ logger.warning(f"Skipping DuckDuckGO Search: {e}")
213
+ return tools
214
 
215
 
216
+ # ── Supabase Retriever ────────────────
217
 
218
  class SupabaseRetriever(BaseRetriever):
219
  material_id: str
 
232
 
233
  # ── RAG Prompt ─────────────────────────────────────────
234
 
235
+ def _rag_prompt(has_web_tools: bool = True, has_knowledge_retriever: bool = False, subject: str = ""):
236
+ tools_list = []
237
+ if has_web_tools:
238
+ tools_list.append("- **Wikipedia Retriever** for general knowledge and conceptual explanations")
239
+ tools_list.append("- **Arxiv Retriever** for academic and scientific research information")
240
+ tools_list.append("- **DuckDuckGo Retriever** for the latest web-based insights")
241
+ if has_knowledge_retriever:
242
+ tools_list.append("- **Knowledge Retriever:** for local learning materials (vector embeddings, summaries, raw text chunks)")
243
+
244
+ tools_section = ""
245
+ if tools_list:
246
+ tools_section = "\nYou have access to these tools:\n" + "\n".join(tools_list)
247
+
248
+ subject_line = f"\nYour current study topic is: **{subject}**." if subject else ""
249
 
250
  return PromptTemplate(
251
  input_variables=["chat_history", "input", "agent_scratchpad", "context"],
252
  template=f"""
253
+ You are a helpful AI study assistant. Your goal is to provide accurate, well-reasoned answers.{subject_line}
254
 
255
+ ## Context Information
 
 
256
  {{context}}
257
  {tools_section}
258
+
259
  ## Instructions:
260
  - Use the available context and tools to answer the user's question as thoroughly as possible.
261
+ - If context is provided, you MUST use it to answer questions.
 
 
262
  - If the context partially answers the question, explain what you know and note any limitations.
263
  - If the context and tools don't contain enough information, use your own knowledge to provide a helpful response and mention that it's based on general knowledge.
264
  - Always provide educational value - explain concepts clearly.
265
+ - If the current study topic appears to be a random string, dummy name, or completely un-understandable gibberish, politely inform the user: "I don't recognize a subject with that name. Please rename your subject topic or specify it clearly here."
266
+
267
+ ## STRICT FORMATTING RULES:
268
+ - IMPORTANT: DO NOT include the labels "Context:", "Instructions:", "Agent Scratchpad:", or "Available tools:" in your final response.
269
+ - CRITICAL: DO NOT repeat the user's query and don't output JSON tool invocations in your final answer. Provide only the plain text explanation.
270
+ - DO NOT use markdown tables or pipe characters (|)
271
+ - DO NOT use separator lines (---, ===)
272
+ - Begin your main response directly or use clear section labels like "Answer:" and "Key Takeaway:"
273
  - Use **Text** for important keywords, topics, or terms you want to highlight
 
274
  - Use numbered lists or bullet points (with a dash -) instead of tables
275
 
 
 
 
 
276
  ---
277
  ### Chat History:
278
  {{chat_history}}
 
300
  input_key="input", memory_key="chat_history", return_messages=True, k=5
301
  )
302
 
303
+ # Fetch material info if material_id is provided
304
+ mat = None
305
  if material_id:
306
+ mat = get_material(material_id)
307
+
308
+ # Determine tool availability
309
+ # Custom topics (no URL/file) should use web tools
310
+ if material_id and mat and mat.get("source_type") != "topic":
311
  tools = []
312
  else:
313
  tools = web_search_tools(has_material=False)
314
 
 
315
  llm = get_groq_llm()
316
 
317
  context_parts = []
318
  has_chunks = False
319
 
320
+ # Inject Subject/Topic
321
+ if mat and mat.get("title"):
322
+ context_parts.append(f"Subject / Topic: {mat.get('title')}")
323
+
324
+ if material_id and mat and mat.get("source_type") != "topic":
325
  results = similarity_search(query, material_id, k=5)
326
  if results:
327
  has_chunks = True
 
333
  context_parts.append(f"Material Summary (No specific excerpts found for your query):\n{summaries}")
334
 
335
  # Fallback: If NO chunks matched AND NO summary was generated, pass start and end chunks
336
+ if not has_chunks and not summaries and material_id and mat and mat.get("source_type") != "topic":
337
  all_chunks = get_chunks(material_id)
338
  if all_chunks:
339
  # Take first 3 and last 2 chunks
 
344
  sampled_text = "\n---\n".join(c["content"] for c in sampled)
345
  context_parts.append(f"Material Sample (No summary found; showing start and end of material):\n{sampled_text}")
346
 
347
+ context_str = "\n\n".join(context_parts) if context_parts else "No specific context provided."
348
+
349
+ has_knowledge = bool(material_id and mat and mat.get("source_type") != "topic")
350
+ subject_title = mat.get("title") if mat and mat.get("title") else ""
351
+ prompt = _rag_prompt(has_web_tools=len(tools) > 0, has_knowledge_retriever=has_knowledge, subject=subject_title)
352
 
353
  if tools:
354
  agent = create_openai_tools_agent(llm, tools, prompt)
 
359
  verbose=False,
360
  return_intermediate_steps=False,
361
  handle_parsing_errors=True,
362
+ max_iterations=3,
363
  )
364
  response = executor.invoke({"input": query, "context": context_str})
365
  return response["output"], memory
 
383
  memory.save_context({"input": query}, {"output": answer})
384
  return answer, memory
385
 
386
+ def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
387
  llm = get_groq_llm()
388
+
389
+ topic_context = ""
390
+ if material_title:
391
+ 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'."
392
+
393
  prompt = PromptTemplate(
394
  input_variables=["query"],
395
+ template=f"Generate a very short, concise title (3-5 words max) for a chat session that starts with this user query: '{{query}}'.{topic_context}\nDo not use quotes or prefixes like 'Title:', just the title itself."
396
  )
397
  chain = prompt | llm
398
  response = chain.invoke({"query": query})
rag/routes.py CHANGED
@@ -1,5 +1,6 @@
1
  import time
2
  import asyncio
 
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from pydantic import BaseModel
5
  from typing import Optional, Any
@@ -18,6 +19,8 @@ from src.store import (
18
  )
19
  from src.summary_generator.summary import clean_summary
20
 
 
 
21
  router = APIRouter(prefix="/api/tutor", tags=["Tutor"])
22
 
23
 
@@ -92,13 +95,12 @@ async def ask_tutor(
92
  else:
93
  answer, memory = await loop.run_in_executor(
94
  None,
95
- lambda: rag_answer(query=body.query, memory=memory)
96
  )
97
  source = "Web Search"
98
 
99
  except Exception as e:
100
- import traceback
101
- traceback.print_exc()
102
  raise HTTPException(500, f"Error generating answer: {e}")
103
 
104
  cleaned_answer = clean_summary(answer)
@@ -189,11 +191,19 @@ async def extract_title(
189
  user_id: str = Depends(get_current_user_id),
190
  ):
191
  try:
 
 
 
 
 
 
 
192
  loop = asyncio.get_event_loop()
193
- title = await loop.run_in_executor(None, lambda: extract_chat_title(body.query))
194
  rename_chat_session(session_id, title)
195
  return {"status": "ok", "title": title}
196
  except Exception as e:
 
197
  raise HTTPException(500, f"Failed to extract title: {e}")
198
 
199
 
 
1
  import time
2
  import asyncio
3
+ import logging
4
  from fastapi import APIRouter, HTTPException, Depends
5
  from pydantic import BaseModel
6
  from typing import Optional, Any
 
19
  )
20
  from src.summary_generator.summary import clean_summary
21
 
22
+ logger = logging.getLogger(__name__)
23
+
24
  router = APIRouter(prefix="/api/tutor", tags=["Tutor"])
25
 
26
 
 
95
  else:
96
  answer, memory = await loop.run_in_executor(
97
  None,
98
+ lambda: rag_answer(query=body.query, material_id=body.material_id, memory=memory)
99
  )
100
  source = "Web Search"
101
 
102
  except Exception as e:
103
+ logger.error(f"Error in ask_tutor: {str(e)}", exc_info=True)
 
104
  raise HTTPException(500, f"Error generating answer: {e}")
105
 
106
  cleaned_answer = clean_summary(answer)
 
191
  user_id: str = Depends(get_current_user_id),
192
  ):
193
  try:
194
+ session = get_chat_session(session_id)
195
+ material_title = None
196
+ if session and session.get("material_id"):
197
+ mat = get_material(session["material_id"])
198
+ if mat:
199
+ material_title = mat.get("title")
200
+
201
  loop = asyncio.get_event_loop()
202
+ title = await loop.run_in_executor(None, lambda: extract_chat_title(body.query, material_title))
203
  rename_chat_session(session_id, title)
204
  return {"status": "ok", "title": title}
205
  except Exception as e:
206
+ logger.error(f"Failed to extract title for session {session_id}: {str(e)}", exc_info=True)
207
  raise HTTPException(500, f"Failed to extract title: {e}")
208
 
209