Spaces:
Running
Running
feat: implement lazy chunk caching for topic-based materials to reduce quiz and summary generation time
Browse files- quiz_generator/routes.py +61 -10
- summary_generator/routes.py +52 -3
- summary_generator/summary.py +36 -17
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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 98 |
-
duck_future = executor.submit(
|
| 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}.
|
| 110 |
-
|
| 111 |
|
| 112 |
combined = "\n\n".join(all_content)
|
| 113 |
-
logger.info(f"
|
|
|
|
|
|
|
| 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 |
-
|
| 121 |
-
logger.info(f"Web summarizer received response of length {len(
|
| 122 |
-
return clean_summary(
|
| 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
|