Hamdy005 commited on
Commit
962a395
·
0 Parent(s):

refactor: restructure project into modular src architecture and implement database store layer

Browse files
__init__.py ADDED
File without changes
config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+
8
+ ENV_PATH = Path(__file__).resolve().parents[1] / "config.env"
9
+ load_dotenv(ENV_PATH)
10
+
11
+
12
+ class Settings:
13
+ openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
14
+ openrouter_base_url: str = os.getenv(
15
+ "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
16
+ )
17
+ supabase_url: str = os.getenv("SUPABASE_URL", "")
18
+ supabase_key: str = os.getenv("SUPABASE_KEY", "")
19
+ supabase_anon_key: str = os.getenv("SUPABASE_ANON_KEY", "")
20
+ model_name: str = os.getenv("MODEL_NAME", "openai/gpt-oss-120b")
21
+ transformers_no_tf: str = os.getenv("TRANSFORMERS_NO_TF", "1")
22
+
23
+
24
+ @lru_cache()
25
+ def get_settings() -> Settings:
26
+ s = Settings()
27
+ if s.openrouter_api_key:
28
+ os.environ["OPENROUTER_API_KEY"] = s.openrouter_api_key
29
+ if s.openrouter_base_url:
30
+ os.environ["OPENROUTER_BASE_URL"] = s.openrouter_base_url
31
+ if "TRANSFORMERS_NO_TF" not in os.environ and s.transformers_no_tf:
32
+ os.environ["TRANSFORMERS_NO_TF"] = s.transformers_no_tf
33
+ return s
34
+
35
+
36
+ settings = get_settings()
database.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from typing import Optional
3
+ from supabase import Client, create_client
4
+ from src.config import settings
5
+
6
+
7
+ @lru_cache()
8
+ def get_supabase() -> Optional[Client]:
9
+ if not settings.supabase_url or not settings.supabase_key:
10
+ return None
11
+ return create_client(settings.supabase_url, settings.supabase_key)
dependencies.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Header, HTTPException
2
+ from typing import Optional
3
+
4
+ DEV_USER_ID = "00000000-0000-0000-0000-000000000001"
5
+
6
+
7
+ async def get_current_user_id(x_user_id: Optional[str] = Header(None)) -> str:
8
+ if x_user_id:
9
+ return x_user_id
10
+ return DEV_USER_ID
main.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from src.materials.routes import router as materials_router
5
+ from src.summary_generator.routes import router as summary_router
6
+ from src.rag.routes import router as tutor_router
7
+ from src.quiz_generator.routes import router as quiz_router
8
+
9
+ app = FastAPI(
10
+ title="AI Tutor API",
11
+ description="Backend API for the AI Tutor for Students application",
12
+ version="1.0.0",
13
+ )
14
+
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"],
18
+ allow_credentials=True,
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ app.include_router(materials_router)
24
+ app.include_router(summary_router)
25
+ app.include_router(tutor_router)
26
+ app.include_router(quiz_router)
27
+
28
+
29
+ @app.get("/api/health")
30
+ async def health_check():
31
+ return {"status": "ok", "service": "AI Tutor API"}
materials/__init__.py ADDED
File without changes
materials/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (184 Bytes). View file
 
materials/__pycache__/routes.cpython-312.pyc ADDED
Binary file (3.66 kB). View file
 
materials/__pycache__/text_utils.cpython-312.pyc ADDED
Binary file (1.41 kB). View file
 
materials/routes.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import validators
3
+ from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
4
+ from pydantic import BaseModel
5
+
6
+ from src.materials.text_utils import text_from_pdf, chunk_text, scrap_website
7
+ from src.rag.rag import store_embeddings
8
+ from src.store import create_material, update_material_status, save_chunks
9
+ from src.dependencies import get_current_user_id
10
+
11
+ router = APIRouter(prefix="/api/materials", tags=["Materials"])
12
+
13
+
14
+ class URLInput(BaseModel):
15
+ url: str
16
+
17
+
18
+ @router.post("/upload-pdf")
19
+ async def upload_pdf(
20
+ file: UploadFile = File(...),
21
+ user_id: str = Depends(get_current_user_id),
22
+ ):
23
+ if not file.filename or not file.filename.endswith(".pdf"):
24
+ raise HTTPException(400, "Only PDF files are accepted")
25
+
26
+ try:
27
+ material = create_material(
28
+ user_id=user_id,
29
+ source_type="pdf",
30
+ title=file.filename,
31
+ )
32
+ material_id = material["id"]
33
+
34
+ raw = text_from_pdf(file.file)
35
+ chunks = chunk_text(raw)
36
+ chunk_ids = save_chunks(material_id, chunks)
37
+
38
+ update_material_status(material_id, "processing")
39
+ store_embeddings(material_id, chunk_ids, chunks)
40
+ update_material_status(material_id, "ready")
41
+
42
+ return {
43
+ "material_id": material_id,
44
+ "title": file.filename,
45
+ "chunks_count": len(chunks),
46
+ }
47
+ except Exception as e:
48
+ if material_id:
49
+ update_material_status(material_id, "failed", str(e))
50
+ raise HTTPException(500, f"Failed to process PDF: {e}")
51
+
52
+
53
+ @router.post("/scrape-url")
54
+ async def scrape_url(
55
+ input: URLInput,
56
+ user_id: str = Depends(get_current_user_id),
57
+ ):
58
+ if not validators.url(input.url):
59
+ raise HTTPException(400, "Invalid URL provided")
60
+
61
+ try:
62
+ material = create_material(
63
+ user_id=user_id,
64
+ source_type="url",
65
+ title=input.url,
66
+ url=input.url,
67
+ )
68
+ material_id = material["id"]
69
+
70
+ raw = scrap_website(input.url)
71
+ chunks = chunk_text(raw, chunk_size=600, chunk_overlap=100)
72
+ chunk_ids = save_chunks(material_id, chunks)
73
+
74
+ update_material_status(material_id, "processing")
75
+ store_embeddings(material_id, chunk_ids, chunks)
76
+ update_material_status(material_id, "ready")
77
+
78
+ return {
79
+ "material_id": material_id,
80
+ "title": input.url,
81
+ "chunks_count": len(chunks),
82
+ }
83
+ except Exception as e:
84
+ if material_id:
85
+ update_material_status(material_id, "failed", str(e))
86
+ raise HTTPException(500, f"Failed to scrape URL: {e}")
materials/text_utils.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import PyPDF2
2
+ 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
quiz_generator/__init__.py ADDED
File without changes
quiz_generator/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (189 Bytes). View file
 
quiz_generator/__pycache__/quiz.cpython-312.pyc ADDED
Binary file (5.8 kB). View file
 
quiz_generator/__pycache__/routes.cpython-312.pyc ADDED
Binary file (4.24 kB). View file
 
quiz_generator/quiz.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ import re
4
+ from typing import Optional
5
+ from langchain.prompts import PromptTemplate
6
+ from langchain.chains import LLMChain
7
+ from langchain.agents import create_openai_tools_agent, AgentExecutor
8
+ from langchain_core.tools import create_retriever_tool
9
+
10
+ from src.rag.rag import get_llm, web_search_tools, SupabaseRetriever
11
+
12
+
13
+ def _quiz_prompt():
14
+ template = """
15
+ You are an expert quiz generator specialized in creating educational and accurate quizzes.
16
+
17
+ **SOURCE PRIORITY:**
18
+ 1. If a retriever tool is available, use it to access the material.
19
+ 2. If a text summary or chunks are provided, rely on that context.
20
+ 3. If no material is available, use the topic to search online.
21
+
22
+ **TASK:**
23
+ Create a {difficulty}-level quiz based on the provided material or topic.
24
+ Include exactly:
25
+ - {mcq_count} multiple choice questions
26
+ - {tf_count} true/false questions
27
+
28
+ **QUESTION REQUIREMENTS:**
29
+ - Each MCQ must have 4 plausible options (A, B, C, D).
30
+ - Answers must reference the labeled option (e.g., "answer": "A) 12.5 cm").
31
+ - All questions must be factually correct.
32
+ - Include concise explanations referencing material or credible sources.
33
+
34
+ **OUTPUT FORMAT (MUST BE VALID JSON):**
35
+ {{
36
+ "quiz_type": "{source_type}",
37
+ "difficulty": "{difficulty}",
38
+ "mcq_count": {mcq_count},
39
+ "tf_count": {tf_count},
40
+ "mcq": [
41
+ {{
42
+ "question": "Question text",
43
+ "options": ["A) Option 1", "B) Option 2", "C) Option 3", "D) Option 4"],
44
+ "answer": "A) Option A",
45
+ "explanation": "Brief factual explanation"
46
+ }}
47
+ ],
48
+ "tf": [
49
+ {{
50
+ "question": "True/False question text",
51
+ "answer": "True",
52
+ "explanation": "Brief factual explanation"
53
+ }}
54
+ ]
55
+ }}
56
+
57
+ **AVAILABLE CONTEXT:**
58
+ {context}
59
+
60
+ **THOUGHTS (optional):**
61
+ {agent_scratchpad}
62
+ """
63
+ return PromptTemplate(
64
+ input_variables=[
65
+ "difficulty", "mcq_count", "tf_count",
66
+ "source_type", "context", "agent_scratchpad",
67
+ ],
68
+ template=template,
69
+ )
70
+
71
+
72
+ def smart_quiz_generator(
73
+ difficulty,
74
+ mcq_count,
75
+ tf_count,
76
+ topic_title=None,
77
+ material_id=None,
78
+ summary=None,
79
+ chunks=None,
80
+ ):
81
+ random_chunks = None
82
+ if chunks:
83
+ sampled = random.sample(chunks, min(10, len(chunks) - 2)) + [chunks[0], chunks[-1]]
84
+ random.shuffle(sampled)
85
+ random_chunks = "\n".join(sampled)
86
+
87
+ if material_id:
88
+ return _contextual_quiz(difficulty, mcq_count, tf_count, random_chunks, material_id)
89
+ elif summary:
90
+ return _summary_quiz(difficulty, mcq_count, tf_count, summary)
91
+ elif chunks:
92
+ return _summary_quiz(difficulty, mcq_count, tf_count, random_chunks)
93
+ elif topic_title:
94
+ return _web_quiz(difficulty, mcq_count, tf_count, topic_title)
95
+ else:
96
+ raise ValueError("No data or topic provided for quiz generation.")
97
+
98
+
99
+ def _summary_quiz(difficulty, mcq_count, tf_count, context_text):
100
+ prompt = _quiz_prompt()
101
+ llm = get_llm()
102
+ chain = LLMChain(llm=llm, prompt=prompt)
103
+ response = chain.run(
104
+ difficulty=difficulty,
105
+ mcq_count=mcq_count,
106
+ tf_count=tf_count,
107
+ source_type="summary",
108
+ context=context_text,
109
+ agent_scratchpad="",
110
+ )
111
+ return _parse_quiz({"output": response})
112
+
113
+
114
+ def _contextual_quiz(difficulty, mcq_count, tf_count, context, material_id):
115
+ prompt = _quiz_prompt()
116
+ llm = get_llm()
117
+
118
+ retriever = SupabaseRetriever(material_id=material_id, k=5)
119
+ retriever_tool = create_retriever_tool(
120
+ retriever,
121
+ name="quiz_material_retriever",
122
+ description="Retrieves relevant content from uploaded materials for quiz generation.",
123
+ )
124
+
125
+ agent = create_openai_tools_agent(llm, [retriever_tool], prompt)
126
+ executor = AgentExecutor(
127
+ agent=agent,
128
+ tools=[retriever_tool],
129
+ verbose=False,
130
+ return_intermediate_steps=False,
131
+ handle_parsing_errors=True,
132
+ )
133
+
134
+ response = executor.invoke({
135
+ "difficulty": difficulty,
136
+ "source_type": "Document Embeddings",
137
+ "mcq_count": mcq_count,
138
+ "tf_count": tf_count,
139
+ "agent_scratchpad": "",
140
+ "context": context,
141
+ })
142
+ return _parse_quiz(response)
143
+
144
+
145
+ def _web_quiz(difficulty, mcq_count, tf_count, topic_title):
146
+ prompt = _quiz_prompt()
147
+ llm = get_llm()
148
+ tools = web_search_tools()
149
+ agent = create_openai_tools_agent(llm, tools, prompt)
150
+
151
+ executor = AgentExecutor(
152
+ agent=agent,
153
+ tools=tools,
154
+ verbose=False,
155
+ return_intermediate_steps=False,
156
+ handle_parsing_errors=True,
157
+ )
158
+
159
+ response = executor.invoke({
160
+ "context": topic_title,
161
+ "difficulty": difficulty,
162
+ "mcq_count": mcq_count,
163
+ "tf_count": tf_count,
164
+ "source_type": "Web Search",
165
+ "agent_scratchpad": "",
166
+ })
167
+ return _parse_quiz(response)
168
+
169
+
170
+ def _parse_quiz(response):
171
+ output = response["output"] if isinstance(response, dict) else str(response)
172
+ cleaned = re.sub(r"```(?:json)?\n?", "", output)
173
+ cleaned = cleaned.replace("```", "").strip()
174
+ match = re.search(r"(\{[\s\S]*\})", cleaned)
175
+ if match:
176
+ cleaned = match.group(1)
177
+ return json.loads(cleaned)
quiz_generator/routes.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+
5
+ from src.quiz_generator.quiz import smart_quiz_generator
6
+ from src.store import get_material, get_chunks, get_summary, save_quiz
7
+ from src.dependencies import get_current_user_id
8
+ from src.config import settings
9
+
10
+ router = APIRouter(prefix="/api/quiz", tags=["Quiz"])
11
+
12
+
13
+ class QuizRequest(BaseModel):
14
+ difficulty: str = "Medium"
15
+ mcq_count: int = 4
16
+ tf_count: int = 3
17
+ source_type: str = "web"
18
+ material_id: Optional[str] = None
19
+ topic: Optional[str] = None
20
+
21
+
22
+ class QuizResponse(BaseModel):
23
+ quiz: dict
24
+ quiz_id: str
25
+
26
+
27
+ @router.post("/generate", response_model=QuizResponse)
28
+ async def generate_quiz(
29
+ body: QuizRequest,
30
+ user_id: str = Depends(get_current_user_id),
31
+ ):
32
+ if body.mcq_count < 1 or body.mcq_count > 20:
33
+ raise HTTPException(400, "MCQ count must be between 1 and 20")
34
+ if body.tf_count < 1 or body.tf_count > 20:
35
+ raise HTTPException(400, "True/False count must be between 1 and 20")
36
+
37
+ try:
38
+ quiz = None
39
+ material_id = body.material_id if body.source_type in ("pdf", "url") else None
40
+
41
+ if body.source_type == "web":
42
+ if not body.topic:
43
+ raise HTTPException(400, "Topic is required for web-based quiz")
44
+ quiz = smart_quiz_generator(
45
+ difficulty=body.difficulty,
46
+ mcq_count=body.mcq_count,
47
+ tf_count=body.tf_count,
48
+ topic_title=body.topic,
49
+ )
50
+
51
+ elif body.source_type in ("pdf", "url"):
52
+ mat = get_material(body.material_id) if body.material_id else None
53
+ if not mat:
54
+ raise HTTPException(400, f"No {body.source_type} material found")
55
+
56
+ chunks_list = get_chunks(body.material_id)
57
+ chunks_texts = [c["content"] for c in chunks_list] if chunks_list else []
58
+ summary_record = get_summary(body.material_id)
59
+ summary_text = summary_record["summary"] if summary_record else None
60
+
61
+ quiz = smart_quiz_generator(
62
+ difficulty=body.difficulty,
63
+ mcq_count=body.mcq_count,
64
+ tf_count=body.tf_count,
65
+ material_id=body.material_id if mat.get("status") == "ready" else None,
66
+ summary=summary_text,
67
+ chunks=chunks_texts,
68
+ )
69
+ else:
70
+ raise HTTPException(400, f"Unknown source_type: {body.source_type}")
71
+
72
+ saved = save_quiz(
73
+ user_id=user_id,
74
+ material_id=material_id,
75
+ source_type=body.source_type,
76
+ difficulty=body.difficulty,
77
+ mcq_count=body.mcq_count,
78
+ tf_count=body.tf_count,
79
+ quiz_data=quiz,
80
+ model_name=settings.model_name,
81
+ )
82
+
83
+ return QuizResponse(quiz=quiz, quiz_id=saved["id"])
84
+ except ValueError as e:
85
+ raise HTTPException(400, str(e))
86
+ except Exception as e:
87
+ raise HTTPException(500, f"Quiz generation failed: {e}")
rag/__init__.py ADDED
File without changes
rag/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (178 Bytes). View file
 
rag/__pycache__/rag.cpython-312.pyc ADDED
Binary file (7.79 kB). View file
 
rag/__pycache__/routes.cpython-312.pyc ADDED
Binary file (3.74 kB). View file
 
rag/rag.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from functools import lru_cache
3
+ from typing import Optional
4
+
5
+ from langchain_huggingface import HuggingFaceEmbeddings
6
+ from langchain.prompts import PromptTemplate
7
+ from langchain.memory import ConversationBufferMemory
8
+ from langchain.agents import create_openai_tools_agent, AgentExecutor
9
+ from langchain_community.tools import ArxivQueryRun, WikipediaQueryRun, DuckDuckGoSearchResults
10
+ from langchain_core.tools.retriever import create_retriever_tool
11
+ from langchain_core.retrievers import BaseRetriever
12
+ from langchain_core.documents import Document
13
+ from langchain_community.utilities import ArxivAPIWrapper, WikipediaAPIWrapper
14
+ from langchain_openai import ChatOpenAI
15
+
16
+ from src.config import settings
17
+ from src.database import get_supabase
18
+
19
+
20
+ # ── Embeddings ─────────────────────────────────────────
21
+
22
+ EMBEDDING_DIM = 384
23
+
24
+ @lru_cache
25
+ def get_embedder():
26
+ return HuggingFaceEmbeddings(
27
+ model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
28
+ model_kwargs={"device": "cpu"},
29
+ encode_kwargs={"normalize_embeddings": True},
30
+ )
31
+
32
+
33
+ def store_embeddings(material_id: str, chunk_ids: list[str], chunks: list[str]):
34
+ embedder = get_embedder()
35
+ embeddings = embedder.embed_documents(chunks)
36
+
37
+ records = [
38
+ {"chunk_id": cid, "material_id": material_id, "embedding": emb}
39
+ for cid, emb in zip(chunk_ids, embeddings)
40
+ ]
41
+
42
+ db = get_supabase()
43
+ for i in range(0, len(records), 50):
44
+ db.table("material_embeddings").insert(records[i:i + 50]).execute()
45
+
46
+
47
+ def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
48
+ embedder = get_embedder()
49
+ query_embedding = embedder.embed_query(query)
50
+
51
+ db = get_supabase()
52
+ result = db.rpc(
53
+ "match_material_chunks",
54
+ {
55
+ "query_embedding": query_embedding,
56
+ "match_material_id": material_id,
57
+ "match_threshold": 0.7,
58
+ "match_count": k,
59
+ },
60
+ ).execute()
61
+
62
+ return result.data
63
+
64
+
65
+ # ── LLM ────────────────────────────────────────────────
66
+
67
+ def get_llm():
68
+ if not os.environ.get("OPENROUTER_API_KEY"):
69
+ raise ValueError("OPENROUTER_API_KEY not found. Please set it in config.env.")
70
+ return ChatOpenAI(
71
+ model=settings.model_name,
72
+ base_url=settings.openrouter_base_url,
73
+ api_key=settings.openrouter_api_key,
74
+ )
75
+
76
+
77
+ # ── Web Search Tools ───────────────────────────────────
78
+
79
+ def web_search_tools():
80
+ wikipedia = WikipediaQueryRun(
81
+ api_wrapper=WikipediaAPIWrapper(top_k_results=3, doc_content_chars_max=7000)
82
+ )
83
+ arxiv = ArxivQueryRun(
84
+ api_wrapper=ArxivAPIWrapper(top_k_results=2, doc_content_chars_max=8000)
85
+ )
86
+ duck = DuckDuckGoSearchResults()
87
+ return [wikipedia, arxiv, duck]
88
+
89
+
90
+ # ── Supabase Retriever (replaces FAISS) ────────────────
91
+
92
+ class SupabaseRetriever(BaseRetriever):
93
+ material_id: str
94
+ k: int = 4
95
+
96
+ def _get_relevant_documents(self, query: str) -> list[Document]:
97
+ results = similarity_search(query, self.material_id, self.k)
98
+ return [
99
+ Document(page_content=r["content"], metadata={
100
+ "similarity": r.get("similarity"),
101
+ "chunk_id": r.get("chunk_id"),
102
+ })
103
+ for r in results
104
+ ]
105
+
106
+
107
+ # ── RAG Prompt ─────────────────────────────────────────
108
+
109
+ def _rag_prompt():
110
+ return PromptTemplate(
111
+ input_variables=["chat_history", "input", "agent_scratchpad"],
112
+ template="""
113
+ You are an advanced AI research assistant specializing in accurate, well-reasoned, and context-aware responses.
114
+ You have access to multiple external tools including:
115
+
116
+ - **Wikipedia Retriever** for general knowledge and conceptual explanations,
117
+ - **Arxiv Retriever** for academic and scientific research information,
118
+ - **DuckDuckGo Retriever** for the latest web-based insights,
119
+ - **Knowledge Retriever:** for local learning materials, which may include:
120
+ - Embedding-based vector databases (most precise and semantic),
121
+ - Summaries (concise overviews of key material),
122
+ - Raw text chunks (unedited extracted text, less refined but detailed).
123
+
124
+ ## Role and Objectives:
125
+ - Provide clear, factual, and logically organized answers.
126
+ - Integrate information from available tools when relevant.
127
+ - Maintain a professional and instructive tone.
128
+ - Respect previous chat context to ensure continuity.
129
+
130
+ ## Reasoning Guidelines:
131
+ 1. Use retrievers only when they can enhance or verify your response.
132
+ 2. Combine retrieved facts with your own reasoning.
133
+ 3. When multiple tools return information, synthesize a unified explanation.
134
+ 4. If information is insufficient, acknowledge the limitation.
135
+ 5. Always focus on clarity, structure, and factual accuracy.
136
+
137
+ ## Response Format:
138
+ - **1. Informed Answer:** Provide a detailed, structured explanation.
139
+ - **2. Final Insight:** Conclude with a short, relevant takeaway.
140
+
141
+ ---
142
+ ### Chat History:
143
+ {chat_history}
144
+
145
+ ### User Query:
146
+ {input}
147
+
148
+ ### Agent Scratchpad:
149
+ {agent_scratchpad}
150
+ """,
151
+ )
152
+
153
+
154
+ # ── RAG Answer ─────────────────────────────────────────
155
+
156
+ def rag_answer(
157
+ query: str,
158
+ material_id: Optional[str] = None,
159
+ chunks: Optional[list[str]] = None,
160
+ summaries: str = "",
161
+ memory: ConversationBufferMemory = None,
162
+ ):
163
+ if memory is None:
164
+ memory = ConversationBufferMemory(
165
+ input_key="input", memory_key="chat_history", return_messages=True
166
+ )
167
+
168
+ prompt = _rag_prompt()
169
+ tools = web_search_tools()
170
+ llm = get_llm()
171
+
172
+ if material_id:
173
+ retriever = SupabaseRetriever(material_id=material_id, k=4)
174
+ retriever_tool = create_retriever_tool(
175
+ retriever,
176
+ name="knowledge_retriever",
177
+ description="Retrieves relevant educational material, notes, and explanations from the knowledge base.",
178
+ )
179
+ tools.append(retriever_tool)
180
+
181
+ agent = create_openai_tools_agent(llm, tools, prompt)
182
+ executor = AgentExecutor(
183
+ agent=agent,
184
+ tools=tools,
185
+ memory=memory,
186
+ verbose=False,
187
+ return_intermediate_steps=False,
188
+ handle_parsing_errors=True,
189
+ )
190
+
191
+ response = executor.invoke({"input": query})
192
+ return response["output"], memory
rag/routes.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+
6
+ from src.rag.rag import rag_answer
7
+ from src.store import get_material, get_chunks, get_summary, get_or_create_memory
8
+
9
+ router = APIRouter(prefix="/api/tutor", tags=["Tutor"])
10
+
11
+
12
+ class TutorQuery(BaseModel):
13
+ query: str
14
+ source_type: str = "web"
15
+ material_id: Optional[str] = None
16
+ memory_id: Optional[str] = None
17
+
18
+
19
+ class TutorResponse(BaseModel):
20
+ answer: str
21
+ source: str
22
+ time_taken: float
23
+ memory_id: str
24
+
25
+
26
+ @router.post("/ask", response_model=TutorResponse)
27
+ async def ask_tutor(body: TutorQuery):
28
+ if not body.query.strip():
29
+ raise HTTPException(400, "Query cannot be empty")
30
+
31
+ memory, memory_id = get_or_create_memory(body.memory_id)
32
+ start = time.time()
33
+
34
+ try:
35
+ if body.source_type in ("pdf", "url"):
36
+ mat = get_material(body.material_id) if body.material_id else None
37
+ if not mat:
38
+ raise HTTPException(400, f"No {body.source_type} material found. Upload one first.")
39
+
40
+ material_id = body.material_id
41
+ chunks_list = get_chunks(material_id)
42
+ chunks_texts = [c["content"] for c in chunks_list] if chunks_list else []
43
+ summary_record = get_summary(material_id)
44
+ summary_text = summary_record["summary"] if summary_record else ""
45
+ status = mat.get("status")
46
+
47
+ if status == "ready":
48
+ answer, memory = rag_answer(
49
+ query=body.query, material_id=material_id, memory=memory
50
+ )
51
+ source = f"{body.source_type.upper()} (embeddings)"
52
+ elif summary_text:
53
+ answer, memory = rag_answer(
54
+ query=body.query, summaries=summary_text, memory=memory
55
+ )
56
+ source = f"{body.source_type.upper()} (summary)"
57
+ elif chunks_texts:
58
+ answer, memory = rag_answer(
59
+ query=body.query, chunks=chunks_texts, memory=memory
60
+ )
61
+ source = f"{body.source_type.upper()} (chunks)"
62
+ else:
63
+ answer, memory = rag_answer(query=body.query, memory=memory)
64
+ source = "Web Search (no material data)"
65
+
66
+ else:
67
+ answer, memory = rag_answer(query=body.query, memory=memory)
68
+ source = "Web Search"
69
+
70
+ except Exception as e:
71
+ raise HTTPException(500, f"Error generating answer: {e}")
72
+
73
+ elapsed = time.time() - start
74
+ return TutorResponse(answer=answer, source=source, time_taken=elapsed, memory_id=memory_id)
store.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from langchain.memory import ConversationBufferMemory
3
+ from src.database import get_supabase
4
+
5
+
6
+ def _db():
7
+ client = get_supabase()
8
+ if client is None:
9
+ raise RuntimeError(
10
+ "Supabase not configured. Set SUPABASE_URL and SUPABASE_KEY in config.env."
11
+ )
12
+ return client
13
+
14
+
15
+ # ── Materials ──────────────────────────────────────────
16
+
17
+ def create_material(user_id: str, source_type: str, title: str,
18
+ file_path: Optional[str] = None,
19
+ url: Optional[str] = None) -> dict:
20
+ data = {
21
+ "user_id": user_id,
22
+ "source_type": source_type,
23
+ "title": title,
24
+ "status": "pending",
25
+ }
26
+ if file_path:
27
+ data["file_path"] = file_path
28
+ if url:
29
+ data["url"] = url
30
+ result = _db().table("materials").insert(data).execute()
31
+ return result.data[0]
32
+
33
+
34
+ def update_material_status(material_id: str, status: str,
35
+ error_message: Optional[str] = None):
36
+ data = {"status": status}
37
+ if error_message:
38
+ data["error_message"] = error_message
39
+ _db().table("materials").update(data).eq("id", material_id).execute()
40
+
41
+
42
+ def get_material(material_id: str) -> Optional[dict]:
43
+ result = _db().table("materials").select("*").eq("id", material_id).execute()
44
+ return result.data[0] if result.data else None
45
+
46
+
47
+ # ── Material Chunks ────────────────────────────────────
48
+
49
+ def save_chunks(material_id: str, chunks: list[str]) -> list[str]:
50
+ records = [
51
+ {"material_id": material_id, "chunk_index": i, "content": c}
52
+ for i, c in enumerate(chunks)
53
+ ]
54
+ result = _db().table("material_chunks").insert(records).execute()
55
+ return [r["id"] for r in result.data]
56
+
57
+
58
+ def get_chunks(material_id: str) -> list[dict]:
59
+ result = (
60
+ _db().table("material_chunks")
61
+ .select("*")
62
+ .eq("material_id", material_id)
63
+ .order("chunk_index")
64
+ .execute()
65
+ )
66
+ return result.data
67
+
68
+
69
+ # ── Summaries ──────────────────────────────────────────
70
+
71
+ def save_summary(material_id: str, user_id: str, summary: str,
72
+ time_taken: float, model_name: str = ""):
73
+ data = {
74
+ "material_id": material_id,
75
+ "user_id": user_id,
76
+ "summary": summary,
77
+ "status": "completed",
78
+ "time_taken": time_taken,
79
+ "model_name": model_name,
80
+ }
81
+ _db().table("summaries").upsert(data, on_conflict=["material_id"]).execute()
82
+
83
+
84
+ def get_summary(material_id: str) -> Optional[dict]:
85
+ result = (
86
+ _db().table("summaries")
87
+ .select("*")
88
+ .eq("material_id", material_id)
89
+ .maybe_single()
90
+ .execute()
91
+ )
92
+ return result.data if result.data else None
93
+
94
+
95
+ # ── Quizzes ────────────────────────────────────────────
96
+
97
+ def save_quiz(user_id: str, material_id: Optional[str], source_type: str,
98
+ difficulty: str, mcq_count: int, tf_count: int,
99
+ quiz_data: dict, model_name: str = "") -> dict:
100
+ data = {
101
+ "user_id": user_id,
102
+ "source_type": source_type,
103
+ "difficulty": difficulty,
104
+ "mcq_count": mcq_count,
105
+ "tf_count": tf_count,
106
+ "quiz_data": quiz_data,
107
+ "status": "completed",
108
+ "model_name": model_name,
109
+ }
110
+ if material_id:
111
+ data["material_id"] = material_id
112
+ result = _db().table("quizzes").insert(data).execute()
113
+ return result.data[0]
114
+
115
+
116
+ # ── Conversation Memory (in-memory, ephemeral) ─────────
117
+
118
+ import uuid as _uuid
119
+
120
+ _memories: dict[str, ConversationBufferMemory] = {}
121
+
122
+
123
+ def get_or_create_memory(memory_id: Optional[str] = None):
124
+ if memory_id and memory_id in _memories:
125
+ return _memories[memory_id], memory_id
126
+ mid = memory_id or str(_uuid.uuid4())
127
+ mem = ConversationBufferMemory(
128
+ input_key="input", memory_key="chat_history", return_messages=True
129
+ )
130
+ _memories[mid] = mem
131
+ return mem, mid
summary_generator/__init__.py ADDED
File without changes
summary_generator/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (192 Bytes). View file
 
summary_generator/__pycache__/routes.cpython-312.pyc ADDED
Binary file (2.66 kB). View file
 
summary_generator/__pycache__/summary.cpython-312.pyc ADDED
Binary file (2.26 kB). View file
 
summary_generator/routes.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from fastapi import APIRouter, HTTPException, Depends
3
+ from pydantic import BaseModel
4
+
5
+ from src.summary_generator.summary import summarizer
6
+ from src.store import get_material, get_chunks, save_summary
7
+ from src.dependencies import get_current_user_id
8
+ from src.config import settings
9
+
10
+ router = APIRouter(prefix="/api/materials", tags=["Summarizer"])
11
+
12
+
13
+ class SummarizeRequest(BaseModel):
14
+ material_id: str
15
+
16
+
17
+ class SummarizeResponse(BaseModel):
18
+ summary: str
19
+ time_taken: float
20
+
21
+
22
+ @router.post("/summarize", response_model=SummarizeResponse)
23
+ async def generate_summary(
24
+ body: SummarizeRequest,
25
+ user_id: str = Depends(get_current_user_id),
26
+ ):
27
+ mat = get_material(body.material_id)
28
+ if not mat:
29
+ raise HTTPException(404, "Material not found")
30
+
31
+ chunks_list = get_chunks(body.material_id)
32
+ if not chunks_list:
33
+ raise HTTPException(400, "No text chunks found in this material")
34
+
35
+ try:
36
+ combined = "\n".join(c["content"] for c in chunks_list)
37
+ start = time.time()
38
+ summary = summarizer(combined)
39
+ elapsed = time.time() - start
40
+
41
+ save_summary(
42
+ material_id=body.material_id,
43
+ user_id=user_id,
44
+ summary=summary,
45
+ time_taken=elapsed,
46
+ model_name=settings.model_name,
47
+ )
48
+
49
+ return SummarizeResponse(summary=summary, time_taken=elapsed)
50
+ except Exception as e:
51
+ raise HTTPException(500, f"Summarization failed: {e}")
summary_generator/summary.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain.prompts import PromptTemplate
2
+ from langchain.chains import LLMChain
3
+ from src.rag.rag import get_llm
4
+
5
+
6
+ def summarizer_prompt():
7
+ return PromptTemplate(
8
+ input_variables=["input"],
9
+ template="""
10
+ You are an expert academic assistant tasked with creating a comprehensive and well-structured summary of educational material.
11
+
12
+ **INSTRUCTIONS:**
13
+ 1. Analyze the provided text and identify the main topics, key concepts, and important details.
14
+ 2. Create a coherent summary that flows logically from introduction to conclusion.
15
+ 3. Focus on educational value - highlight definitions, theories, examples, and practical applications.
16
+ 4. Maintain academic tone while ensuring clarity and accessibility.
17
+ 5. Organize the summary with clear sections and logical progression.
18
+ 6. If the content contains a list of facts, make sure the final summary presents them as a numbered list.
19
+
20
+ **STRUCTURE YOUR SUMMARY AS FOLLOWS:**
21
+ - **Overview**: Begin with a 2-3 sentence high-level overview of the entire content
22
+ - **Key Topics**: List the main topics covered in the material
23
+ - **Detailed Summary**: Provide a comprehensive section-by-section summary covering all important concepts
24
+ - **Key Takeaways**: Highlight the most important points, definitions, and conclusions
25
+ - **Educational Value**: Explain how this material helps in understanding the subject
26
+
27
+ **CONTENT TO SUMMARIZE:**
28
+ {input}
29
+
30
+ **REMEMBER:**
31
+ - Be thorough but concise
32
+ - Maintain academic accuracy
33
+ - Use clear, educational language
34
+ - Focus on what would be most helpful for a student studying this material
35
+ """,
36
+ )
37
+
38
+
39
+ def summarizer(text: str) -> str:
40
+ prompt = summarizer_prompt()
41
+ llm = get_llm()
42
+ chain = LLMChain(llm=llm, prompt=prompt, verbose=False)
43
+ return chain.run(input=text)