Hamdy005 commited on
Commit
6c898a6
·
1 Parent(s): 1716986

feat: implement lazy chunk caching for topic-based materials to reduce quiz and summary generation time

Browse files
quiz_generator/routes.py CHANGED
@@ -3,7 +3,7 @@ import logging
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from typing import Optional
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
- from src.store import get_material, get_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results, check_daily_limit, increment_daily_usage, ADMIN_EMAILS
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
  from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
@@ -56,20 +56,71 @@ async def generate_quiz(
56
  if mat.get("user_id") != user_id:
57
  raise HTTPException(403, "Access denied")
58
  topic_title = mat.get("title")
59
-
60
  if not topic_title:
61
  raise HTTPException(400, "Topic title or valid material_id is required for web-based quiz")
62
 
63
  loop = asyncio.get_event_loop()
64
- quiz = await loop.run_in_executor(
65
- None,
66
- lambda: smart_quiz_generator(
67
- difficulty=body.difficulty,
68
- mcq_count=body.mcq_count,
69
- tf_count=body.tf_count,
70
- topic_title=topic_title,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  )
72
- )
73
 
74
  elif body.source_type in ("pdf", "url"):
75
  mat = get_material(body.material_id) if body.material_id else None
 
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from typing import Optional
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
+ from src.store import get_material, get_chunks, save_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results, check_daily_limit, increment_daily_usage, ADMIN_EMAILS
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
  from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
 
56
  if mat.get("user_id") != user_id:
57
  raise HTTPException(403, "Access denied")
58
  topic_title = mat.get("title")
59
+
60
  if not topic_title:
61
  raise HTTPException(400, "Topic title or valid material_id is required for web-based quiz")
62
 
63
  loop = asyncio.get_event_loop()
64
+
65
+ # ── Lazy cache check ──────────────────────────────────────────────
66
+ # If this topic material already has cached chunks (from a prior
67
+ # summary/quiz), use the contextual path (vector retriever).
68
+ # Otherwise fetch web content now, cache it, then generate.
69
+ existing_chunks = []
70
+ if body.material_id:
71
+ existing_chunks = await loop.run_in_executor(None, get_chunks, body.material_id)
72
+
73
+ if existing_chunks:
74
+ # Fast path — use cached embeddings via SupabaseRetriever
75
+ logger.info(
76
+ f"Topic '{topic_title}' has {len(existing_chunks)} cached chunks — "
77
+ "using contextual quiz path."
78
+ )
79
+ chunks_texts = [c["content"] for c in existing_chunks]
80
+ quiz = await loop.run_in_executor(
81
+ None,
82
+ lambda: smart_quiz_generator(
83
+ difficulty=body.difficulty,
84
+ mcq_count=body.mcq_count,
85
+ tf_count=body.tf_count,
86
+ material_id=body.material_id, # triggers SupabaseRetriever
87
+ chunks=chunks_texts,
88
+ )
89
+ )
90
+
91
+ else:
92
+ # First-time path — fetch web content, cache it, then quiz
93
+ logger.info(
94
+ f"Topic '{topic_title}' has no cached chunks — fetching web content for quiz."
95
+ )
96
+ from src.summary_generator.summary import fetch_web_content
97
+ from src.materials.text_utils import chunk_text
98
+ from src.rag.rag import store_embeddings_async
99
+
100
+ raw_content = await loop.run_in_executor(None, fetch_web_content, topic_title)
101
+
102
+ chunks_texts = await loop.run_in_executor(
103
+ None, lambda: chunk_text(raw_content, chunk_size=600, chunk_overlap=100)
104
+ )
105
+ if chunks_texts and body.material_id:
106
+ chunk_ids = await loop.run_in_executor(
107
+ None, save_chunks, body.material_id, chunks_texts
108
+ )
109
+ await store_embeddings_async(body.material_id, chunk_ids, chunks_texts)
110
+ logger.info(
111
+ f"Cached {len(chunks_texts)} chunks for topic '{topic_title}'."
112
+ )
113
+
114
+ quiz = await loop.run_in_executor(
115
+ None,
116
+ lambda: smart_quiz_generator(
117
+ difficulty=body.difficulty,
118
+ mcq_count=body.mcq_count,
119
+ tf_count=body.tf_count,
120
+ topic_title=topic_title,
121
+ chunks=chunks_texts if chunks_texts else None,
122
+ )
123
  )
 
124
 
125
  elif body.source_type in ("pdf", "url"):
126
  mat = get_material(body.material_id) if body.material_id else None
summary_generator/routes.py CHANGED
@@ -3,8 +3,14 @@ import time
3
  import logging
4
  from fastapi import APIRouter, HTTPException, Depends
5
 
6
- from src.summary_generator.summary import summarizer, web_summarizer
7
- from src.store import get_material, get_chunks, save_summary, get_summary as get_stored_summary, check_daily_limit, increment_daily_usage
 
 
 
 
 
 
8
  from src.dependencies import get_current_user_id, get_current_user
9
  from src.config import settings
10
  from .schemas import SummarizeRequest, SummarizeResponse
@@ -37,7 +43,50 @@ async def generate_summary(
37
 
38
  if mat.get("source_type") == "topic":
39
  topic_title = mat.get("title", "topic")
40
- summary = await loop.run_in_executor(None, web_summarizer, topic_title)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  else:
42
  chunks_list = get_chunks(body.material_id)
43
  if not chunks_list:
 
3
  import logging
4
  from fastapi import APIRouter, HTTPException, Depends
5
 
6
+ from src.summary_generator.summary import summarizer, web_summarizer, fetch_web_content
7
+ from src.materials.text_utils import chunk_text
8
+ from src.rag.rag import store_embeddings_async
9
+ from src.store import (
10
+ get_material, get_chunks, save_chunks, save_summary,
11
+ get_summary as get_stored_summary, update_material_status,
12
+ check_daily_limit, increment_daily_usage,
13
+ )
14
  from src.dependencies import get_current_user_id, get_current_user
15
  from src.config import settings
16
  from .schemas import SummarizeRequest, SummarizeResponse
 
43
 
44
  if mat.get("source_type") == "topic":
45
  topic_title = mat.get("title", "topic")
46
+
47
+ # ── Lazy cache check ───────────────────────────────────────────────
48
+ # Check whether we already have stored chunks for this topic.
49
+ # If yes: use them directly (no web fetching).
50
+ # If no: fetch web content, chunk + embed it, then summarize.
51
+ existing_chunks = await loop.run_in_executor(None, get_chunks, body.material_id)
52
+
53
+ if existing_chunks:
54
+ # Fast path — use cached chunks
55
+ logger.info(
56
+ f"Topic '{topic_title}' has {len(existing_chunks)} cached chunks — "
57
+ "skipping web fetch for summarization."
58
+ )
59
+ combined = "\n".join(c["content"] for c in existing_chunks)
60
+ if len(combined) > MAX_COMBINED_TEXT_LEN:
61
+ half = MAX_COMBINED_TEXT_LEN // 2
62
+ combined = combined[:half] + combined[-half:]
63
+ summary = await loop.run_in_executor(None, summarizer, combined)
64
+
65
+ else:
66
+ # First-time path — fetch, cache, then summarize
67
+ logger.info(
68
+ f"Topic '{topic_title}' has no cached chunks — fetching web content."
69
+ )
70
+ raw_content = await loop.run_in_executor(None, fetch_web_content, topic_title)
71
+
72
+ # Chunk + store + embed (mirrors the URL pipeline)
73
+ chunks_texts = await loop.run_in_executor(
74
+ None, lambda: chunk_text(raw_content, chunk_size=600, chunk_overlap=100)
75
+ )
76
+ if chunks_texts:
77
+ chunk_ids = await loop.run_in_executor(
78
+ None, save_chunks, body.material_id, chunks_texts
79
+ )
80
+ await store_embeddings_async(body.material_id, chunk_ids, chunks_texts)
81
+ logger.info(
82
+ f"Cached {len(chunks_texts)} chunks for topic '{topic_title}'."
83
+ )
84
+
85
+ # Generate summary from the raw fetched content
86
+ summary = await loop.run_in_executor(
87
+ None, web_summarizer, topic_title, raw_content
88
+ )
89
+
90
  else:
91
  chunks_list = get_chunks(body.material_id)
92
  if not chunks_list:
summary_generator/summary.py CHANGED
@@ -68,12 +68,18 @@ def summarizer(text: str) -> str:
68
  raise
69
 
70
 
71
- def web_summarizer(topic: str) -> str:
72
- logger.info(f"Web summarizer started for topic: {topic}")
73
-
74
- all_content = []
75
-
76
- def fetch_wikipedia():
 
 
 
 
 
 
77
  try:
78
  wiki_api = WikipediaAPIWrapper(
79
  top_k_results=WIKI_TOP_K_RESULTS,
@@ -84,7 +90,7 @@ def web_summarizer(topic: str) -> str:
84
  logger.warning(f"Wikipedia search for '{topic}' failed: {e}")
85
  return ""
86
 
87
- def fetch_duckduckgo():
88
  try:
89
  duck_api = DuckDuckGoSearchAPIWrapper()
90
  return duck_api.run(topic)
@@ -92,34 +98,47 @@ def web_summarizer(topic: str) -> str:
92
  logger.warning(f"DuckDuckGo search for '{topic}' failed: {e}")
93
  return ""
94
 
95
- # Execute searches in parallel to minimize latency
96
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
97
- wiki_future = executor.submit(fetch_wikipedia)
98
- duck_future = executor.submit(fetch_duckduckgo)
99
-
100
  wiki_content = wiki_future.result()
101
  duck_content = duck_future.result()
102
 
 
103
  if wiki_content and wiki_content.strip():
104
  all_content.append(f"--- Wikipedia ---\n{wiki_content}")
105
  if duck_content and duck_content.strip():
106
  all_content.append(f"--- Web Search ---\n{duck_content}")
107
 
108
  if not all_content:
109
- logger.warning(f"No content found for topic: {topic}. Falling back to general knowledge.")
110
- all_content.append(f"No web content found for: {topic}")
111
 
112
  combined = "\n\n".join(all_content)
113
- logger.info(f"Web search combined text length for topic '{topic}': {len(combined)}")
 
 
114
 
 
 
 
 
 
 
 
 
 
 
 
115
  combined = _truncate_text(combined)
 
116
  try:
117
  llm = get_llm()
118
  chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | llm
119
  response = chain.invoke({"topic": topic, "input": combined})
120
- raw_content = response.content
121
- logger.info(f"Web summarizer received response of length {len(raw_content)}")
122
- return clean_summary(raw_content)
123
  except Exception as e:
124
  logger.error(f"Web summarizer failed: {str(e)}", exc_info=True)
125
  raise
 
68
  raise
69
 
70
 
71
+ def fetch_web_content(topic: str) -> str:
72
+ """
73
+ Fetch raw Wikipedia + DuckDuckGo content for *topic* and return the combined
74
+ text string. This is a pure data-fetching helper — no LLM is called.
75
+
76
+ The returned text can be:
77
+ - Chunked and stored in the DB for future reuse.
78
+ - Passed directly to an LLM prompt as context.
79
+ """
80
+ logger.info(f"fetch_web_content started for topic: {topic}")
81
+
82
+ def _fetch_wikipedia():
83
  try:
84
  wiki_api = WikipediaAPIWrapper(
85
  top_k_results=WIKI_TOP_K_RESULTS,
 
90
  logger.warning(f"Wikipedia search for '{topic}' failed: {e}")
91
  return ""
92
 
93
+ def _fetch_duckduckgo():
94
  try:
95
  duck_api = DuckDuckGoSearchAPIWrapper()
96
  return duck_api.run(topic)
 
98
  logger.warning(f"DuckDuckGo search for '{topic}' failed: {e}")
99
  return ""
100
 
 
101
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
102
+ wiki_future = executor.submit(_fetch_wikipedia)
103
+ duck_future = executor.submit(_fetch_duckduckgo)
 
104
  wiki_content = wiki_future.result()
105
  duck_content = duck_future.result()
106
 
107
+ all_content = []
108
  if wiki_content and wiki_content.strip():
109
  all_content.append(f"--- Wikipedia ---\n{wiki_content}")
110
  if duck_content and duck_content.strip():
111
  all_content.append(f"--- Web Search ---\n{duck_content}")
112
 
113
  if not all_content:
114
+ logger.warning(f"No content found for topic: {topic}. Returning placeholder.")
115
+ return f"No web content found for: {topic}"
116
 
117
  combined = "\n\n".join(all_content)
118
+ logger.info(f"fetch_web_content combined length for '{topic}': {len(combined)}")
119
+ return combined
120
+
121
 
122
+ def web_summarizer(topic: str, raw_content: str | None = None) -> str:
123
+ """
124
+ Generate an LLM summary for *topic*.
125
+
126
+ If *raw_content* is provided (pre-fetched web text) it is used directly,
127
+ skipping the Wiki/DDG network calls. Otherwise fetch_web_content() is
128
+ called internally so this function stays usable standalone.
129
+ """
130
+ logger.info(f"Web summarizer started for topic: {topic}")
131
+
132
+ combined = raw_content if raw_content is not None else fetch_web_content(topic)
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