False45 commited on
Commit
8a1fc38
·
1 Parent(s): d056dcd

feat: complete Phase 11 & 12, finalize Hackathon submission with ADK, LangGraph, and React

Browse files
Dockerfile CHANGED
@@ -1,24 +1,30 @@
1
- FROM python:3.11-slim
 
2
 
3
  WORKDIR /app
 
 
4
 
5
- # Install system dependencies for sentence-transformers and OCR
6
- RUN apt-get update && apt-get install -y --no-install-recommends \
7
- build-essential \
8
- && rm -rf /var/lib/apt/lists/*
9
 
10
- # Install Python dependencies
11
- COPY requirements.txt .
12
- RUN pip install --no-cache-dir -r requirements.txt
 
 
13
 
14
  # Copy application code
15
- COPY src/ ./src/
16
- COPY ../knowledge_base.py ./knowledge_base.py
 
 
 
17
 
18
- # Non-root user for security
19
- RUN useradd --create-home appuser && chown -R appuser /app
20
- USER appuser
21
 
22
- EXPOSE 8080
23
 
24
- CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]
 
 
1
+ # Build stage
2
+ FROM python:3.11-slim as builder
3
 
4
  WORKDIR /app
5
+ COPY requirements.txt .
6
+ RUN pip install --user --no-cache-dir -r requirements.txt
7
 
8
+ # Run stage
9
+ FROM python:3.11-slim
 
 
10
 
11
+ WORKDIR /app
12
+
13
+ # Copy dependencies from builder
14
+ COPY --from=builder /root/.local /root/.local
15
+ ENV PATH=/root/.local/bin:$PATH
16
 
17
  # Copy application code
18
+ COPY src /app/backend/src
19
+
20
+ # Set environment variables
21
+ ENV PYTHONPATH=/app
22
+ ENV ENVIRONMENT=production
23
 
24
+ # Cloud Run injects PORT, but default to 8080
25
+ ENV PORT=8080
 
26
 
27
+ EXPOSE $PORT
28
 
29
+ # Start FastAPI application
30
+ CMD ["sh", "-c", "uvicorn backend.src.main:app --host 0.0.0.0 --port ${PORT}"]
requirements.txt CHANGED
@@ -7,10 +7,14 @@ langchain-groq>=0.1.0
7
  langchain-huggingface>=0.0.3
8
  langchain-google-genai>=1.0.6
9
  google-generativeai>=0.5.4
 
 
 
10
 
11
  # Vector DB
12
- chromadb>=0.4.0
13
- faiss-cpu>=1.7.4
 
14
 
15
  # Embeddings — pinned for meta-tensor safety on Python 3.11 / CPU
16
  sentence-transformers==2.7.0
@@ -24,9 +28,8 @@ accelerate>=0.29.0
24
  unstructured>=0.10.0
25
  pypdf>=3.0.0
26
 
27
- # Database (memory)
28
- pymongo>=4.0.0
29
- motor>=3.0.0
30
 
31
  # Math
32
  sympy>=1.12
@@ -52,3 +55,8 @@ plotly>=5.0.0
52
  pytest>=7.0.0
53
  pytest-cov>=4.0.0
54
  httpx>=0.27.0
 
 
 
 
 
 
7
  langchain-huggingface>=0.0.3
8
  langchain-google-genai>=1.0.6
9
  google-generativeai>=0.5.4
10
+ google-adk==0.1.0
11
+ langgraph>=0.0.26
12
+ langsmith>=0.1.0
13
 
14
  # Vector DB
15
+ qdrant-client>=1.7.0
16
+ fastembed>=0.1.3
17
+ langchain-qdrant>=0.1.0
18
 
19
  # Embeddings — pinned for meta-tensor safety on Python 3.11 / CPU
20
  sentence-transformers==2.7.0
 
28
  unstructured>=0.10.0
29
  pypdf>=3.0.0
30
 
31
+ # Database (Firebase)
32
+ firebase-admin>=6.5.0
 
33
 
34
  # Math
35
  sympy>=1.12
 
55
  pytest>=7.0.0
56
  pytest-cov>=4.0.0
57
  httpx>=0.27.0
58
+ google-cloud-logging>=3.10.0
59
+ slowapi>=0.1.9
60
+ RestrictedPython>=6.2
61
+ matplotlib>=3.8.0
62
+ PyPDF2>=3.0.1
src/agents/__init__.py ADDED
File without changes
src/agents/formatter.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatter Agent (Google ADK)
3
+
4
+ Formats the final verified solution according to the SYSTEM_TEMPLATE constraints
5
+ (e.g., emojis, teacher reactions, LaTeX math mode).
6
+ """
7
+
8
+ import logging
9
+ from backend.src.services.llm_service import _get_llm
10
+ from backend.src.config import SYSTEM_TEMPLATE
11
+
12
+ try:
13
+ from google_adk.agent import Agent
14
+ except ImportError:
15
+ class Agent:
16
+ def __init__(self, name, description, instructions, llm=None):
17
+ self.name = name
18
+ self.description = description
19
+ self.instructions = instructions
20
+ self.llm = llm
21
+
22
+ logger = logging.getLogger("math_assistant.agents.formatter")
23
+
24
+ class FormatterAgent(Agent):
25
+ """Formats the solution."""
26
+
27
+ def __init__(self):
28
+ super().__init__(
29
+ name="FormatterAgent",
30
+ description="Formats the mathematical solution based on strict system templates.",
31
+ instructions=(
32
+ f"{SYSTEM_TEMPLATE}\n\n"
33
+ "Your task is to take the provided raw mathematical solution and rewrite it "
34
+ "to strictly adhere to the above formatting rules, including LaTeX math mode, "
35
+ "teacher reactions, and class-level appropriate depth."
36
+ )
37
+ )
38
+ self.llm = _get_llm()
39
+
40
+ def format(self, query: str, raw_solution: str, class_level: int) -> str:
41
+ """Format the solution."""
42
+ try:
43
+ prompt = (
44
+ f"{self.instructions}\n\n"
45
+ f"Target Class Level: {class_level}\n\n"
46
+ f"Original Query: {query}\n\n"
47
+ f"Raw Solution to Format:\n{raw_solution}"
48
+ )
49
+
50
+ if hasattr(self.llm, "invoke"):
51
+ response = self.llm.invoke(prompt)
52
+ return response.content
53
+ else:
54
+ return self.llm(prompt)
55
+
56
+ except Exception as e:
57
+ logger.error(f"Formatter failed: {e}")
58
+ return raw_solution
src/agents/mcp_registry.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import asyncio
4
+ from typing import List
5
+ from langchain.tools import Tool
6
+ from backend.src.config import settings
7
+
8
+ def get_mcp_tools() -> List[Tool]:
9
+ """Dynamically discover and return MCP tools."""
10
+ tools = []
11
+
12
+ if not getattr(settings, "USE_MCP", True):
13
+ # Fallback to direct SymPy calls
14
+ import sympy as sp
15
+ from sympy.parsing.sympy_parser import parse_expr
16
+
17
+ def fallback_differentiate(expr: str) -> str:
18
+ return str(sp.diff(parse_expr(expr), sp.Symbol('x')))
19
+
20
+ tools.append(Tool(
21
+ name="differentiate",
22
+ description="Calculate the derivative of a mathematical expression.",
23
+ func=fallback_differentiate
24
+ ))
25
+ return tools
26
+
27
+ # In a full production setup, this would use mcp.client.stdio.stdio_client to connect
28
+ # to the 6 subprocesses. For latency and simplicity in this sandbox, we'll import
29
+ # the server tool functions directly but wrap them as LangChain tools, simulating
30
+ # an MCP Tool discovery registry.
31
+
32
+ import sys
33
+ import importlib.util
34
+ base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../mcp-servers'))
35
+
36
+ def load_module_from_path(module_name, file_path):
37
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
38
+ module = importlib.util.module_from_spec(spec)
39
+ sys.modules[module_name] = module
40
+ spec.loader.exec_module(module)
41
+ return module
42
+
43
+ try:
44
+ sympy_mcp = load_module_from_path("sympy_mcp", os.path.join(base_dir, "sympy-mcp", "server.py"))
45
+ calc_mcp = load_module_from_path("calculator_mcp", os.path.join(base_dir, "calculator-mcp", "server.py"))
46
+ graph_mcp = load_module_from_path("graph_plotter_mcp", os.path.join(base_dir, "graph-plotter-mcp", "server.py"))
47
+ pdf_mcp = load_module_from_path("pdf_reader_mcp", os.path.join(base_dir, "pdf-reader-mcp", "server.py"))
48
+ exec_mcp = load_module_from_path("python_executor_mcp", os.path.join(base_dir, "python-executor-mcp", "server.py"))
49
+ img_mcp = load_module_from_path("image_solver_mcp", os.path.join(base_dir, "image-solver-mcp", "server.py"))
50
+
51
+ tools.extend([
52
+ Tool(name="differentiate", description="Calculate derivative of expr in terms of x", func=lambda x: str(sympy_mcp.differentiate(x))),
53
+ Tool(name="integrate", description="Calculate integral of expr in terms of x", func=lambda x: str(sympy_mcp.integrate(x))),
54
+ Tool(name="simplify", description="Simplify mathematical expression", func=lambda x: str(sympy_mcp.simplify(x))),
55
+ Tool(name="solve", description="Solve algebraic equation equal to 0", func=lambda x: str(sympy_mcp.solve(x))),
56
+ Tool(name="execute_python", description="Execute sandboxed python code. Returns stdout.", func=exec_mcp.execute_python),
57
+ Tool(name="plot_function", description="Plot a math function string into SVG", func=graph_mcp.plot_function),
58
+ Tool(name="read_pdf", description="Read base64 PDF", func=pdf_mcp.read_pdf),
59
+ Tool(name="extract_and_solve_image", description="Extract and solve math from base64 image", func=lambda x: str(img_mcp.extract_and_solve_image(x)))
60
+ ])
61
+ except Exception as e:
62
+ import logging
63
+ logging.getLogger(__name__).error(f"Failed to load MCP tools: {e}")
64
+
65
+ return tools
src/agents/memory.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory Agent (Google ADK)
3
+
4
+ Retrieves chat history and analyzes weak topics from the user's profile.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, Any
9
+
10
+ from backend.src.services.memory_service import FirestoreChatMemory
11
+ from backend.src.services.firebase_service import get_firestore_client
12
+ from backend.src.config import settings
13
+
14
+ try:
15
+ from google_adk.agent import Agent
16
+ except ImportError:
17
+ class Agent:
18
+ def __init__(self, name, description, instructions, llm=None):
19
+ self.name = name
20
+ self.description = description
21
+ self.instructions = instructions
22
+ self.llm = llm
23
+
24
+ logger = logging.getLogger("math_assistant.agents.memory")
25
+
26
+ class MemoryAgent(Agent):
27
+ """Retrieves user memory and weak topics."""
28
+
29
+ def __init__(self):
30
+ super().__init__(
31
+ name="MemoryAgent",
32
+ description="Retrieves past conversation context and user weak topics.",
33
+ instructions="Fetch user memory context."
34
+ )
35
+
36
+ def retrieve(self, uid: str, session_id: str) -> Dict[str, Any]:
37
+ """Fetch history and weak topics."""
38
+ # 1. Get Chat History
39
+ memory_svc = FirestoreChatMemory(uid=uid, session_id=session_id)
40
+ history = memory_svc.get_history(limit=5)
41
+
42
+ formatted_history = []
43
+ for msg in history:
44
+ role = msg.get("role", "user")
45
+ content = msg.get("content", "")
46
+ formatted_history.append(f"{role.capitalize()}: {content}")
47
+
48
+ history_text = "\n".join(formatted_history) if formatted_history else "No previous conversation."
49
+
50
+ # 2. Get Weak Topics
51
+ weak_topics = []
52
+ db = get_firestore_client()
53
+ if db:
54
+ try:
55
+ doc = db.collection("users").document(uid).collection("profile").document("topics").get()
56
+ if doc.exists:
57
+ weak_topics = doc.to_dict().get("weak_topics", [])
58
+ except Exception as e:
59
+ logger.error(f"Failed to fetch weak topics: {e}")
60
+
61
+ return {
62
+ "history": history_text,
63
+ "weak_topics": weak_topics
64
+ }
src/agents/orchestrator.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ADK Orchestrator
3
+
4
+ Chains the 5 agents together (Planner -> Memory -> Solver -> Verifier -> Formatter).
5
+ Replaces the monolithic MathAIEngine.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any
10
+
11
+ from backend.src.agents.planner import PlannerAgent
12
+ from backend.src.agents.memory import MemoryAgent
13
+ from backend.src.agents.solver import SolverAgent
14
+ from backend.src.agents.verifier import VerifierAgent
15
+ from backend.src.agents.formatter import FormatterAgent
16
+
17
+ logger = logging.getLogger("math_assistant.agents.orchestrator")
18
+
19
+ class ADKOrchestrator:
20
+ """Orchestrates the multi-agent ADK pipeline."""
21
+
22
+ def __init__(self, uid: str, session_id: str):
23
+ self.uid = uid
24
+ self.session_id = session_id
25
+
26
+ self.planner = PlannerAgent()
27
+ self.memory = MemoryAgent()
28
+ self.solver = SolverAgent()
29
+ self.verifier = VerifierAgent()
30
+ self.formatter = FormatterAgent()
31
+
32
+ def query(self, user_query: str) -> Dict[str, Any]:
33
+ """Execute the full agent pipeline."""
34
+ logger.info(f"Starting ADK Orchestrator for session {self.session_id}")
35
+
36
+ # 1. Plan
37
+ plan = self.planner.plan(user_query)
38
+ logger.info(f"Plan: {plan}")
39
+ class_level = plan.get("class", 10)
40
+
41
+ # 2. Memory Retrieval
42
+ mem_context = self.memory.retrieve(self.uid, self.session_id)
43
+
44
+ # 3. Solver + RAG
45
+ rag_context = self.solver.retrieve_context(user_query)
46
+ raw_solution = self.solver.solve(user_query, rag_context, mem_context["history"])
47
+
48
+ # 4. Verify
49
+ # We allow up to 2 retries on verification failure
50
+ verification = self.verifier.verify(user_query, raw_solution)
51
+ retries = 0
52
+ while not verification.get("is_correct", True) and retries < 2:
53
+ logger.warning(f"Verification failed: {verification.get('feedback')}. Retrying...")
54
+ feedback = verification.get("feedback")
55
+ # Inject feedback into prompt
56
+ refined_query = f"{user_query}\nNote: Your previous attempt had an error: {feedback}. Please fix it."
57
+ raw_solution = self.solver.solve(refined_query, rag_context, mem_context["history"])
58
+ verification = self.verifier.verify(user_query, raw_solution)
59
+ retries += 1
60
+
61
+ # 5. Format
62
+ final_answer = self.formatter.format(user_query, raw_solution, class_level)
63
+
64
+ # Save to memory (Memory agent handles retrieval, but we use memory service directly for saving)
65
+ from backend.src.services.memory_service import FirestoreChatMemory
66
+ mem_svc = FirestoreChatMemory(uid=self.uid, session_id=self.session_id)
67
+ mem_svc.add_message("human", user_query)
68
+ mem_svc.add_message("assistant", final_answer)
69
+
70
+ return {
71
+ "answer": final_answer,
72
+ "session_id": self.session_id,
73
+ "is_adk": True
74
+ }
src/agents/planner.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Planner Agent (Google ADK)
3
+
4
+ Responsible for classifying the incoming query into a problem type and
5
+ target educational class.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any
10
+
11
+ from backend.src.services.llm_service import _get_llm
12
+ from backend.src.config import settings
13
+
14
+ try:
15
+ from google_adk.agent import Agent
16
+ except ImportError:
17
+ # Mock fallback for test environment if ADK is missing
18
+ class Agent:
19
+ def __init__(self, name, description, instructions, llm=None):
20
+ self.name = name
21
+ self.description = description
22
+ self.instructions = instructions
23
+ self.llm = llm
24
+
25
+ logger = logging.getLogger("math_assistant.agents.planner")
26
+
27
+ class PlannerAgent(Agent):
28
+ """Classifies math problems into type and class level."""
29
+
30
+ def __init__(self):
31
+ super().__init__(
32
+ name="PlannerAgent",
33
+ description="Analyzes mathematical queries to classify them by type and class level.",
34
+ instructions=(
35
+ "You are an expert curriculum analyzer. Your job is to classify the given "
36
+ "mathematical query.\n"
37
+ "Return ONLY a valid JSON object with two keys:\n"
38
+ "- 'type': A string representing the question type (e.g., 'ncert_exercise', 'concept_explanation', 'jee_advanced').\n"
39
+ "- 'class': An integer representing the target class level (e.g., 6, 8, 10, 11, 12). If unknown, use 10.\n"
40
+ "Example output: {\"type\": \"ncert_exercise\", \"class\": 10}"
41
+ )
42
+ )
43
+ self.llm = _get_llm()
44
+
45
+ def plan(self, query: str) -> Dict[str, Any]:
46
+ """Classify the query."""
47
+ try:
48
+ # Simple invocation of LLM for the agent
49
+ if hasattr(self.llm, "invoke"):
50
+ response = self.llm.invoke(f"{self.instructions}\n\nQuery: {query}")
51
+ content = response.content
52
+ else:
53
+ content = self.llm(f"{self.instructions}\n\nQuery: {query}")
54
+
55
+ import json
56
+ import re
57
+
58
+ # Extract JSON from response
59
+ match = re.search(r'\{.*\}', content, re.DOTALL)
60
+ if match:
61
+ return json.loads(match.group(0))
62
+
63
+ return {"type": "unknown", "class": 10}
64
+
65
+ except Exception as e:
66
+ logger.error(f"Planner failed: {e}")
67
+ return {"type": "unknown", "class": 10}
src/agents/solver.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Solver Agent (Google ADK)
3
+
4
+ Uses RAG context and LLM to solve the math problem.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, Any, List
9
+
10
+ from backend.src.services.llm_service import _get_llm
11
+ from backend.src.services.vector_service import build_pipeline
12
+ from backend.src.config import settings
13
+
14
+ try:
15
+ from google_adk.agent import Agent
16
+ except ImportError:
17
+ class Agent:
18
+ def __init__(self, name, description, instructions, llm=None):
19
+ self.name = name
20
+ self.description = description
21
+ self.instructions = instructions
22
+ self.llm = llm
23
+
24
+ logger = logging.getLogger("math_assistant.agents.solver")
25
+
26
+ class SolverAgent(Agent):
27
+ """Core mathematical engine."""
28
+
29
+ def __init__(self):
30
+ super().__init__(
31
+ name="SolverAgent",
32
+ description="Solves mathematical problems step-by-step using RAG context.",
33
+ instructions="Solve the math problem accurately."
34
+ )
35
+ self.llm = _get_llm()
36
+ self.vector_store = build_pipeline()
37
+
38
+ from backend.src.agents.mcp_registry import get_mcp_tools
39
+ self.tools = get_mcp_tools()
40
+
41
+ try:
42
+ from langchain.agents import create_tool_calling_agent, AgentExecutor
43
+ from langchain_core.prompts import ChatPromptTemplate
44
+
45
+ prompt = ChatPromptTemplate.from_messages([
46
+ ("system", "You are an advanced mathematical solver. Provide a clear, step-by-step solution. You MUST use the provided tools for differentiation, integration, arithmetic, python code execution, etc. whenever a complex calculation is needed. Do not guess complex answers."),
47
+ ("human", "Question: {query}\n\nRelevant Knowledge Base Context:\n{context}\n\nPrevious Chat Context:\n{history}\n\nSolution:")
48
+ ])
49
+
50
+ # If the LLM doesn't support bind_tools, this will fail or we can catch it
51
+ if getattr(settings, "USE_MCP", True) and hasattr(self.llm, "bind_tools") and self.tools:
52
+ agent = create_tool_calling_agent(self.llm, self.tools, prompt)
53
+ self.executor = AgentExecutor(agent=agent, tools=self.tools, verbose=True, handle_parsing_errors=True)
54
+ else:
55
+ self.executor = None
56
+ except ImportError:
57
+ self.executor = None
58
+
59
+ def solve(self, query: str, context: str, history: str) -> str:
60
+ """Generate a solution based on query, RAG context, and history."""
61
+ if getattr(self, 'executor', None):
62
+ try:
63
+ response = self.executor.invoke({
64
+ "query": query,
65
+ "context": context,
66
+ "history": history
67
+ })
68
+ return response.get("output", "")
69
+ except Exception as e:
70
+ logger.error(f"Solver AgentExecutor failed: {e}")
71
+ return "I encountered an error while trying to solve this problem with tools."
72
+
73
+ prompt = (
74
+ f"You are a mathematical solver. Provide a clear, step-by-step solution.\n\n"
75
+ f"Question: {query}\n\n"
76
+ f"Relevant Knowledge Base Context:\n{context}\n\n"
77
+ f"Previous Chat Context:\n{history}\n\n"
78
+ f"Solution:"
79
+ )
80
+
81
+ try:
82
+ if hasattr(self.llm, "invoke"):
83
+ response = self.llm.invoke(prompt)
84
+ return response.content
85
+ else:
86
+ return self.llm(prompt)
87
+ except Exception as e:
88
+ logger.error(f"Solver failed: {e}")
89
+ return "I encountered an error while trying to solve this problem."
90
+
91
+ def retrieve_context(self, query: str) -> str:
92
+ """Retrieve RAG context for the query."""
93
+ try:
94
+ retriever = self.vector_store.as_retriever(search_kwargs={"k": 3})
95
+ docs = retriever.invoke(query)
96
+ if not docs:
97
+ return "No relevant context found."
98
+ return "\n\n".join([doc.page_content for doc in docs])
99
+ except Exception as e:
100
+ logger.warning(f"RAG retrieval failed in Solver: {e}")
101
+ return "No relevant context found."
src/agents/verifier.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Verifier Agent (Google ADK)
3
+
4
+ Checks the solver's output for mathematical or logical errors.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, Any
9
+
10
+ from backend.src.services.llm_service import _get_llm
11
+
12
+ try:
13
+ from google_adk.agent import Agent
14
+ except ImportError:
15
+ class Agent:
16
+ def __init__(self, name, description, instructions, llm=None):
17
+ self.name = name
18
+ self.description = description
19
+ self.instructions = instructions
20
+ self.llm = llm
21
+
22
+ logger = logging.getLogger("math_assistant.agents.verifier")
23
+
24
+ class VerifierAgent(Agent):
25
+ """Verifies mathematical solutions."""
26
+
27
+ def __init__(self):
28
+ super().__init__(
29
+ name="VerifierAgent",
30
+ description="Verifies the step-by-step solution for mathematical errors.",
31
+ instructions=(
32
+ "You are a strict math reviewer. Review the provided solution for the given query.\n"
33
+ "Are there any mathematical errors, calculation mistakes, or logical leaps?\n"
34
+ "For example, check for basic arithmetic errors like 4+4=9.\n"
35
+ "Return ONLY a valid JSON object with two keys:\n"
36
+ "- 'is_correct': A boolean (true/false).\n"
37
+ "- 'feedback': A string explaining the error if is_correct is false, or 'Looks good.' if true.\n"
38
+ )
39
+ )
40
+ self.llm = _get_llm()
41
+
42
+ def verify(self, query: str, solution: str) -> Dict[str, Any]:
43
+ """Verify the solution."""
44
+ try:
45
+ prompt = f"{self.instructions}\n\nQuery: {query}\n\nSolution:\n{solution}"
46
+
47
+ if hasattr(self.llm, "invoke"):
48
+ response = self.llm.invoke(prompt)
49
+ content = response.content
50
+ else:
51
+ content = self.llm(prompt)
52
+
53
+ import json
54
+ import re
55
+
56
+ # Extract JSON from response
57
+ match = re.search(r'\{.*\}', content, re.DOTALL)
58
+ if match:
59
+ return json.loads(match.group(0))
60
+
61
+ return {"is_correct": True, "feedback": "Could not parse verification."}
62
+
63
+ except Exception as e:
64
+ logger.error(f"Verifier failed: {e}")
65
+ return {"is_correct": True, "feedback": "Verification failed."}
src/api/limiter.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from slowapi import Limiter, _rate_limit_exceeded_handler
2
+ from slowapi.util import get_remote_address
3
+ from slowapi.errors import RateLimitExceeded
4
+
5
+ # Global rate limiter using the remote address as the key.
6
+ limiter = Limiter(key_func=get_remote_address)
src/api/middleware/auth.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Firebase Authentication Middleware.
3
+
4
+ Validates Firebase ID tokens passed in the Authorization header.
5
+ Used as a FastAPI dependency for protected routes.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from fastapi import Depends, HTTPException, status
12
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
13
+
14
+ from backend.src.services.firebase_service import get_auth
15
+ from backend.src.config import settings
16
+
17
+ logger = logging.getLogger("math_assistant.auth")
18
+ security = HTTPBearer(auto_error=False)
19
+
20
+ def verify_firebase_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> str:
21
+ """Verify the Firebase ID token and return the user's UID.
22
+
23
+ If USE_FIREBASE=false or no token is provided, returns 'anonymous'.
24
+ If a token is provided but invalid, raises HTTP 401.
25
+ """
26
+ if not settings.use_firebase:
27
+ return "anonymous"
28
+
29
+ auth = get_auth()
30
+ if not auth:
31
+ # Firebase not initialized properly
32
+ return "anonymous"
33
+
34
+ if not credentials:
35
+ # Require authentication when Firebase is enabled
36
+ raise HTTPException(
37
+ status_code=status.HTTP_401_UNAUTHORIZED,
38
+ detail="Missing or invalid authentication token",
39
+ headers={"WWW-Authenticate": "Bearer"},
40
+ )
41
+
42
+ token = credentials.credentials
43
+
44
+ # For testing: accept a dummy token
45
+ if token == "test-token":
46
+ return "test-user-uid"
47
+
48
+ try:
49
+ decoded_token = auth.verify_id_token(token)
50
+ uid = decoded_token.get("uid")
51
+ if not uid:
52
+ raise ValueError("Token does not contain a UID")
53
+ return uid
54
+ except Exception as e:
55
+ logger.warning(f"Firebase token verification failed: {e}")
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail=f"Invalid authentication token: {e}",
59
+ headers={"WWW-Authenticate": "Bearer"},
60
+ )
src/api/v1/chat.py CHANGED
@@ -8,7 +8,7 @@ through the MathAIEngine (RAG + symbolic math + LLM with fallback).
8
  import logging
9
  from typing import Optional
10
 
11
- from fastapi import APIRouter, HTTPException, UploadFile, File, Form
12
  from fastapi.responses import StreamingResponse
13
  from pydantic import BaseModel, Field
14
 
@@ -16,6 +16,10 @@ from backend.src.services.llm_service import MathAIEngine
16
  from backend.src.services.vector_service import build_pipeline
17
  from backend.src.services.gemini_service import GeminiVisionService
18
  from backend.src.config import settings
 
 
 
 
19
 
20
  logger = logging.getLogger("math_assistant.api.chat")
21
 
@@ -55,7 +59,8 @@ class VisionResponse(BaseModel):
55
  # ── Endpoints ──────────────────────────────────────────────────────────
56
 
57
  @router.post("/chat", response_model=ChatResponse)
58
- async def chat(request: ChatRequest):
 
59
  """Process a math question and return a step-by-step solution.
60
 
61
  The query flows through:
@@ -65,17 +70,40 @@ async def chat(request: ChatRequest):
65
  4. LLM call with automatic model fallback
66
  """
67
  try:
68
- store = build_pipeline()
69
- engine = MathAIEngine(vector_store=store, session_id=request.session_id)
70
- result = engine.query(request.query)
71
- return ChatResponse(**result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  except Exception as e:
73
  logger.error(f"Chat endpoint error: {e}")
74
  raise HTTPException(status_code=500, detail=str(e))
75
 
76
 
77
  @router.post("/chat/stream")
78
- async def chat_stream(request: ChatRequest):
 
79
  """Stream a math solution token-by-token using SSE.
80
 
81
  Requires USE_GEMINI=true to function correctly.
@@ -89,19 +117,20 @@ async def chat_stream(request: ChatRequest):
89
  try:
90
  # We manually orchestrate the context retrieval for streaming
91
  store = build_pipeline()
92
- engine = MathAIEngine(vector_store=store, session_id=request.session_id)
 
93
 
94
  # 1. Get context and history
95
- source_docs, context = engine._retrieve_context(request.query)
96
  chat_history = engine.memory.get_langchain_messages(limit=4)
97
- engine.memory.add_message("human", request.query)
98
 
99
  gemini = GeminiService()
100
 
101
  async def generate():
102
  full_response = ""
103
  try:
104
- async for chunk in gemini.stream(request.query, context=context, chat_history=chat_history):
105
  full_response += chunk
106
  # Format as SSE
107
  yield f"data: {chunk}\n\n"
@@ -122,7 +151,8 @@ async def chat_stream(request: ChatRequest):
122
 
123
 
124
  @router.post("/vision/extract", response_model=VisionResponse)
125
- async def extract_math_from_image(file: UploadFile = File(...), solve: bool = Form(False)):
 
126
  """Extract math from an uploaded image using Gemini Vision."""
127
  if not settings.use_gemini:
128
  raise HTTPException(status_code=400, detail="Vision extraction requires Gemini API.")
@@ -147,10 +177,11 @@ async def extract_math_from_image(file: UploadFile = File(...), solve: bool = Fo
147
 
148
 
149
  @router.get("/history/{session_id}", response_model=HistoryResponse)
150
- async def get_history(session_id: str):
151
  """Get chat history for a given session."""
152
  try:
153
  engine = MathAIEngine(session_id=session_id)
 
154
  history = engine.get_history()
155
  # Convert MongoDB ObjectId to string for JSON serialization
156
  messages = []
@@ -167,10 +198,11 @@ async def get_history(session_id: str):
167
 
168
 
169
  @router.delete("/history/{session_id}")
170
- async def clear_history(session_id: str):
171
  """Clear chat history for a given session."""
172
  try:
173
  engine = MathAIEngine(session_id=session_id)
 
174
  engine.clear_memory()
175
  return {"status": "ok", "message": f"History cleared for session {session_id}"}
176
  except Exception as e:
 
8
  import logging
9
  from typing import Optional
10
 
11
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends, Request
12
  from fastapi.responses import StreamingResponse
13
  from pydantic import BaseModel, Field
14
 
 
16
  from backend.src.services.vector_service import build_pipeline
17
  from backend.src.services.gemini_service import GeminiVisionService
18
  from backend.src.config import settings
19
+ from backend.src.api.middleware.auth import verify_firebase_token
20
+ from backend.src.api.limiter import limiter
21
+ from backend.src.agents.orchestrator import ADKOrchestrator
22
+ from backend.src.graph.math_graph import math_graph
23
 
24
  logger = logging.getLogger("math_assistant.api.chat")
25
 
 
59
  # ── Endpoints ──────────────────────────────────────────────────────────
60
 
61
  @router.post("/chat", response_model=ChatResponse)
62
+ @limiter.limit("20/minute")
63
+ async def chat(request: Request, payload: ChatRequest, uid: str = Depends(verify_firebase_token)):
64
  """Process a math question and return a step-by-step solution.
65
 
66
  The query flows through:
 
70
  4. LLM call with automatic model fallback
71
  """
72
  try:
73
+ if getattr(settings, "use_langgraph", False):
74
+ # Phase 5: LangGraph Path
75
+ initial_state = {
76
+ "user_query": payload.query,
77
+ "uid": uid,
78
+ "session_id": payload.session_id,
79
+ "retries": 0
80
+ }
81
+ final_state = math_graph.invoke(initial_state)
82
+ return ChatResponse(
83
+ answer=final_state.get("final_answer", "Error generating response."),
84
+ session_id=payload.session_id,
85
+ is_adk=True
86
+ )
87
+ elif settings.use_adk:
88
+ # Phase 4: ADK Orchestrator Path
89
+ orchestrator = ADKOrchestrator(uid=uid, session_id=payload.session_id)
90
+ result = orchestrator.query(payload.query)
91
+ return ChatResponse(**result)
92
+ else:
93
+ # Legacy Phase 2/3 path
94
+ store = build_pipeline()
95
+ engine = MathAIEngine(vector_store=store, session_id=payload.session_id)
96
+ engine.memory.uid = uid
97
+ result = engine.query(payload.query)
98
+ return ChatResponse(**result)
99
  except Exception as e:
100
  logger.error(f"Chat endpoint error: {e}")
101
  raise HTTPException(status_code=500, detail=str(e))
102
 
103
 
104
  @router.post("/chat/stream")
105
+ @limiter.limit("20/minute")
106
+ async def chat_stream(request: Request, payload: ChatRequest, uid: str = Depends(verify_firebase_token)):
107
  """Stream a math solution token-by-token using SSE.
108
 
109
  Requires USE_GEMINI=true to function correctly.
 
117
  try:
118
  # We manually orchestrate the context retrieval for streaming
119
  store = build_pipeline()
120
+ engine = MathAIEngine(vector_store=store, session_id=payload.session_id)
121
+ engine.memory.uid = uid
122
 
123
  # 1. Get context and history
124
+ source_docs, context = engine._retrieve_context(payload.query)
125
  chat_history = engine.memory.get_langchain_messages(limit=4)
126
+ engine.memory.add_message("human", payload.query)
127
 
128
  gemini = GeminiService()
129
 
130
  async def generate():
131
  full_response = ""
132
  try:
133
+ async for chunk in gemini.stream(payload.query, context=context, chat_history=chat_history):
134
  full_response += chunk
135
  # Format as SSE
136
  yield f"data: {chunk}\n\n"
 
151
 
152
 
153
  @router.post("/vision/extract", response_model=VisionResponse)
154
+ @limiter.limit("20/minute")
155
+ async def extract_math_from_image(request: Request, file: UploadFile = File(...), solve: bool = Form(False), uid: str = Depends(verify_firebase_token)):
156
  """Extract math from an uploaded image using Gemini Vision."""
157
  if not settings.use_gemini:
158
  raise HTTPException(status_code=400, detail="Vision extraction requires Gemini API.")
 
177
 
178
 
179
  @router.get("/history/{session_id}", response_model=HistoryResponse)
180
+ async def get_history(session_id: str, uid: str = Depends(verify_firebase_token)):
181
  """Get chat history for a given session."""
182
  try:
183
  engine = MathAIEngine(session_id=session_id)
184
+ engine.memory.uid = uid
185
  history = engine.get_history()
186
  # Convert MongoDB ObjectId to string for JSON serialization
187
  messages = []
 
198
 
199
 
200
  @router.delete("/history/{session_id}")
201
+ async def clear_history(session_id: str, uid: str = Depends(verify_firebase_token)):
202
  """Clear chat history for a given session."""
203
  try:
204
  engine = MathAIEngine(session_id=session_id)
205
+ engine.memory.uid = uid
206
  engine.clear_memory()
207
  return {"status": "ok", "message": f"History cleared for session {session_id}"}
208
  except Exception as e:
src/api/v1/progress.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Progress Tracking API — /api/v1/progress endpoints.
3
+
4
+ Handles daily streak calculations and total problems solved per user.
5
+ Backed by Firestore.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from pydantic import BaseModel
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException
13
+
14
+ from backend.src.api.middleware.auth import verify_firebase_token
15
+ from backend.src.services.firebase_service import get_firestore_client
16
+
17
+ logger = logging.getLogger("math_assistant.api.progress")
18
+
19
+ router = APIRouter(prefix="/api/v1/progress", tags=["progress"])
20
+
21
+ class ProgressResponse(BaseModel):
22
+ streak: int
23
+ total_solved: int
24
+ last_solved_date: str
25
+
26
+ @router.get("", response_model=ProgressResponse)
27
+ async def get_progress(uid: str = Depends(verify_firebase_token)):
28
+ """Get the user's current progress and streak."""
29
+ db = get_firestore_client()
30
+ if not db:
31
+ # Fallback if Firebase is disabled
32
+ return ProgressResponse(streak=0, total_solved=0, last_solved_date="")
33
+
34
+ try:
35
+ doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
36
+ doc = doc_ref.get()
37
+
38
+ if doc.exists:
39
+ data = doc.to_dict()
40
+ return ProgressResponse(
41
+ streak=data.get("streak", 0),
42
+ total_solved=data.get("total_solved", 0),
43
+ last_solved_date=data.get("last_solved_date", "")
44
+ )
45
+ else:
46
+ return ProgressResponse(streak=0, total_solved=0, last_solved_date="")
47
+ except Exception as e:
48
+ logger.error(f"Error getting progress for {uid}: {e}")
49
+ raise HTTPException(status_code=500, detail=str(e))
50
+
51
+ @router.post("/increment", response_model=ProgressResponse)
52
+ async def increment_progress(uid: str = Depends(verify_firebase_token)):
53
+ """Increment the user's total solved count and update their streak."""
54
+ db = get_firestore_client()
55
+ if not db:
56
+ return ProgressResponse(streak=1, total_solved=1, last_solved_date=datetime.now(timezone.utc).date().isoformat())
57
+
58
+ try:
59
+ doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
60
+ today_str = datetime.now(timezone.utc).date().isoformat()
61
+
62
+ # We need a transaction to safely update the streak
63
+ @from_firestore_transaction(db)
64
+ def update_in_transaction(transaction, ref, today):
65
+ snapshot = ref.get(transaction=transaction)
66
+
67
+ if not snapshot.exists:
68
+ data = {
69
+ "streak": 1,
70
+ "total_solved": 1,
71
+ "last_solved_date": today
72
+ }
73
+ transaction.set(ref, data)
74
+ return data
75
+
76
+ current_data = snapshot.to_dict()
77
+ last_date = current_data.get("last_solved_date", "")
78
+ streak = current_data.get("streak", 0)
79
+ total = current_data.get("total_solved", 0) + 1
80
+
81
+ if last_date == today:
82
+ # Already solved a problem today, just increment total
83
+ pass
84
+ else:
85
+ # Need to check if it was yesterday
86
+ try:
87
+ last_date_obj = datetime.fromisoformat(last_date).date()
88
+ today_obj = datetime.fromisoformat(today).date()
89
+ delta = (today_obj - last_date_obj).days
90
+
91
+ if delta == 1:
92
+ streak += 1
93
+ else:
94
+ streak = 1
95
+ except ValueError:
96
+ streak = 1
97
+
98
+ data = {
99
+ "streak": streak,
100
+ "total_solved": total,
101
+ "last_solved_date": today
102
+ }
103
+ transaction.update(ref, data)
104
+ return data
105
+
106
+ new_stats = update_in_transaction(db.transaction(), doc_ref, today_str)
107
+ return ProgressResponse(**new_stats)
108
+
109
+ except Exception as e:
110
+ logger.error(f"Error incrementing progress for {uid}: {e}")
111
+ raise HTTPException(status_code=500, detail=str(e))
112
+
113
+ def from_firestore_transaction(db):
114
+ """Helper decorator for Firestore transactions."""
115
+ from firebase_admin import firestore
116
+ return firestore.transactional
src/config.py CHANGED
@@ -55,10 +55,22 @@ class Settings(BaseSettings):
55
  chunk_overlap: int = 200
56
  collection_name: str = "math_knowledge_base"
57
 
58
- # ── MongoDB (Chat Memory) ──────────────────────────────────────────
59
- mongodb_uri: str = ""
60
- mongodb_db_name: str = "math_assistant"
61
- mongodb_collection: str = "chat_history"
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  # ── Server ─────────────────────────────────────────────────────────
64
  host: str = "0.0.0.0"
@@ -70,6 +82,34 @@ class Settings(BaseSettings):
70
  # Singleton settings instance
71
  settings = Settings()
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  # ── System Prompt Template ─────────────────────────────────────────────
75
  # Extracted from main.py L404-L522. This is the master prompt template
 
55
  chunk_overlap: int = 200
56
  collection_name: str = "math_knowledge_base"
57
 
58
+ # ── Firebase ───────────────────────────────────────────────────────
59
+ use_firebase: bool = True
60
+ firebase_credentials_path: str = ""
61
+
62
+ # ── ADK Multi-Agent ────────────────────────────────────────────────
63
+ use_adk: bool = True
64
+
65
+ # ── LangGraph ──────────────────────────────────────────────────────
66
+ use_langgraph: bool = True
67
+ langchain_api_key: str = ""
68
+
69
+ # ── Vector DB ──────────────────────────────────────────────────────
70
+ vector_db: str = "qdrant"
71
+ qdrant_url: str = ":memory:"
72
+ qdrant_api_key: str = ""
73
+
74
 
75
  # ── Server ─────────────────────────────────────────────────────────
76
  host: str = "0.0.0.0"
 
82
  # Singleton settings instance
83
  settings = Settings()
84
 
85
+ def setup_logging():
86
+ import logging
87
+ import os
88
+
89
+ # Check if running in a GCP environment (Cloud Run, App Engine) or has ADC setup
90
+ use_cloud_logging = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or os.getenv("GOOGLE_CLOUD_PROJECT")
91
+
92
+ if use_cloud_logging:
93
+ try:
94
+ import google.cloud.logging
95
+ client = google.cloud.logging.Client()
96
+ client.setup_logging()
97
+ logging.info("Google Cloud Logging integrated successfully.")
98
+ return
99
+ except ImportError:
100
+ logging.warning("google-cloud-logging not installed. Falling back to standard logging.")
101
+ except Exception as e:
102
+ logging.warning(f"Failed to initialize Cloud Logging: {e}. Falling back to standard logging.")
103
+
104
+ # Standard local logging fallback
105
+ logging.basicConfig(
106
+ level=logging.INFO,
107
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
108
+ )
109
+
110
+ # Initialize logging when config is imported
111
+ setup_logging()
112
+
113
 
114
  # ── System Prompt Template ─────────────────────────────────────────────
115
  # Extracted from main.py L404-L522. This is the master prompt template
src/graph/__init__.py ADDED
File without changes
src/graph/math_graph.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MathGraph Definition
3
+
4
+ Builds the StateGraph and connects nodes with conditional routing.
5
+ """
6
+
7
+ import logging
8
+ from langgraph.graph import StateGraph, START, END
9
+
10
+ from backend.src.graph.state import MathAgentState
11
+ from backend.src.graph.nodes.nodes import (
12
+ classify_node,
13
+ retrieve_rag_node,
14
+ retrieve_memory_node,
15
+ solve_node,
16
+ verify_node,
17
+ format_node
18
+ )
19
+
20
+ logger = logging.getLogger("math_assistant.graph.math_graph")
21
+
22
+ def should_retry(state: MathAgentState):
23
+ """Conditional edge logic after verification."""
24
+ if state["is_correct"]:
25
+ return "format"
26
+ elif state["retries"] >= 2:
27
+ logger.warning(f"Max retries reached. Forcing format. Feedback: {state.get('verification_feedback')}")
28
+ return "format"
29
+ else:
30
+ logger.info(f"Verification failed. Retrying solve... (Retry {state['retries']})")
31
+ return "solve"
32
+
33
+ # 1. Initialize StateGraph
34
+ graph_builder = StateGraph(MathAgentState)
35
+
36
+ # 2. Add Nodes
37
+ graph_builder.add_node("classify", classify_node)
38
+ graph_builder.add_node("retrieve_rag", retrieve_rag_node)
39
+ graph_builder.add_node("retrieve_memory", retrieve_memory_node)
40
+ graph_builder.add_node("solve", solve_node)
41
+ graph_builder.add_node("verify", verify_node)
42
+ graph_builder.add_node("format", format_node)
43
+
44
+ # 3. Add Edges
45
+ # Start -> Classify
46
+ graph_builder.add_edge(START, "classify")
47
+
48
+ # Classify -> Parallel Retrievals
49
+ graph_builder.add_edge("classify", "retrieve_rag")
50
+ graph_builder.add_edge("classify", "retrieve_memory")
51
+
52
+ # Parallel Retrievals -> Solve
53
+ # LangGraph waits for all incoming edges to complete before firing 'solve'
54
+ graph_builder.add_edge("retrieve_rag", "solve")
55
+ graph_builder.add_edge("retrieve_memory", "solve")
56
+
57
+ # Solve -> Verify
58
+ graph_builder.add_edge("solve", "verify")
59
+
60
+ # Verify -> Conditional Routing
61
+ graph_builder.add_conditional_edges(
62
+ "verify",
63
+ should_retry,
64
+ {
65
+ "format": "format",
66
+ "solve": "solve"
67
+ }
68
+ )
69
+
70
+ # Format -> End
71
+ graph_builder.add_edge("format", END)
72
+
73
+ # 4. Compile Graph
74
+ math_graph = graph_builder.compile()
src/graph/nodes/__init__.py ADDED
File without changes
src/graph/nodes/nodes.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph Nodes
3
+
4
+ Wraps the ADK agents into functional nodes that mutate the MathAgentState.
5
+ """
6
+
7
+ import logging
8
+ from backend.src.graph.state import MathAgentState
9
+ from backend.src.agents.planner import PlannerAgent
10
+ from backend.src.agents.memory import MemoryAgent
11
+ from backend.src.agents.solver import SolverAgent
12
+ from backend.src.agents.verifier import VerifierAgent
13
+ from backend.src.agents.formatter import FormatterAgent
14
+ from backend.src.services.memory_service import FirestoreChatMemory
15
+
16
+ logger = logging.getLogger("math_assistant.graph.nodes")
17
+
18
+ # Lazy initialization of agents
19
+ _agents = {}
20
+
21
+ def _get_agent(agent_class, name):
22
+ if name not in _agents:
23
+ _agents[name] = agent_class()
24
+ return _agents[name]
25
+
26
+ def classify_node(state: MathAgentState) -> dict:
27
+ """Classify the user query."""
28
+ planner = _get_agent(PlannerAgent, "planner")
29
+ plan = planner.plan(state["user_query"])
30
+ return {
31
+ "question_type": plan.get("type", "unknown"),
32
+ "class_level": plan.get("class", 10),
33
+ "retries": 0 # initialize retries
34
+ }
35
+
36
+ def retrieve_rag_node(state: MathAgentState) -> dict:
37
+ """Fetch RAG context from the vector store."""
38
+ solver = _get_agent(SolverAgent, "solver")
39
+ context = solver.retrieve_context(state["user_query"])
40
+ return {"rag_context": context}
41
+
42
+ def retrieve_memory_node(state: MathAgentState) -> dict:
43
+ """Fetch history and weak topics from Firestore."""
44
+ memory = _get_agent(MemoryAgent, "memory")
45
+ mem = memory.retrieve(state["uid"], state["session_id"])
46
+ return {
47
+ "history": mem.get("history", ""),
48
+ "weak_topics": mem.get("weak_topics", [])
49
+ }
50
+
51
+ def solve_node(state: MathAgentState) -> dict:
52
+ """Generate a solution based on current state context."""
53
+ query = state["user_query"]
54
+ solver = _get_agent(SolverAgent, "solver")
55
+
56
+ # If this is a retry, append the verifier's feedback
57
+ if state.get("retries", 0) > 0 and state.get("verification_feedback"):
58
+ query = f"{query}\nNote: Your previous attempt failed validation: {state['verification_feedback']}. Fix it."
59
+
60
+ solution = solver.solve(query, state.get("rag_context", ""), state.get("history", ""))
61
+ return {"raw_solution": solution}
62
+
63
+ def verify_node(state: MathAgentState) -> dict:
64
+ """Verify the generated solution."""
65
+ verifier = _get_agent(VerifierAgent, "verifier")
66
+ verification = verifier.verify(state["user_query"], state["raw_solution"])
67
+ return {
68
+ "is_correct": verification.get("is_correct", True),
69
+ "verification_feedback": verification.get("feedback", ""),
70
+ "retries": state.get("retries", 0) + 1
71
+ }
72
+
73
+ def format_node(state: MathAgentState) -> dict:
74
+ """Format the final output and save it to chat history."""
75
+ formatter = _get_agent(FormatterAgent, "formatter")
76
+ final_answer = formatter.format(
77
+ state["user_query"],
78
+ state["raw_solution"],
79
+ state.get("class_level", 10)
80
+ )
81
+
82
+ # Side effect: Save the conversation turn
83
+ mem_svc = FirestoreChatMemory(uid=state["uid"], session_id=state["session_id"])
84
+ mem_svc.add_message("human", state["user_query"])
85
+ mem_svc.add_message("assistant", final_answer)
86
+
87
+ return {"final_answer": final_answer}
src/graph/state.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph State Definition
3
+ """
4
+
5
+ from typing import TypedDict, Annotated, List
6
+
7
+ class MathAgentState(TypedDict):
8
+ """Represents the state of the math orchestration pipeline."""
9
+
10
+ # Input
11
+ user_query: str
12
+ uid: str
13
+ session_id: str
14
+
15
+ # Planner output
16
+ question_type: str
17
+ class_level: int
18
+
19
+ # Retrievals (Running in parallel)
20
+ rag_context: str
21
+ history: str
22
+ weak_topics: List[str]
23
+
24
+ # Solver Output
25
+ raw_solution: str
26
+
27
+ # Verifier Feedback
28
+ is_correct: bool
29
+ verification_feedback: str
30
+ retries: int
31
+
32
+ # Formatter Output
33
+ final_answer: str
src/main.py CHANGED
@@ -13,7 +13,9 @@ from fastapi import FastAPI
13
  from fastapi.middleware.cors import CORSMiddleware
14
 
15
  from backend.src.config import settings
16
- from backend.src.api.v1.chat import router as chat_router
 
 
17
 
18
  # ── Logging Setup ──────────────────────────────────────────────────────
19
  logging.basicConfig(
@@ -32,6 +34,9 @@ async def lifespan(app: FastAPI):
32
  logger.info(f" LLM Model: {settings.llm_model}")
33
  logger.info(f" Vector DB: {settings.vector_db_type}")
34
 
 
 
 
35
  # Pre-build knowledge base on startup (optional, lazy by default)
36
  # Uncomment the following to eagerly build on startup:
37
  # from backend.src.services.vector_service import build_pipeline
@@ -53,6 +58,11 @@ app = FastAPI(
53
  lifespan=lifespan,
54
  )
55
 
 
 
 
 
 
56
  # ── CORS Middleware ────────────────────────────────────────────────────
57
  app.add_middleware(
58
  CORSMiddleware,
@@ -67,7 +77,8 @@ app.add_middleware(
67
  )
68
 
69
  # ── Routes ─────────────────────────────────────────────────────────────
70
- app.include_router(chat_router)
 
71
 
72
 
73
  @app.get("/health")
 
13
  from fastapi.middleware.cors import CORSMiddleware
14
 
15
  from backend.src.config import settings
16
+ from backend.src.services.firebase_service import init_firebase
17
+ from backend.src.api.v1 import chat
18
+ from backend.src.api.v1 import progress
19
 
20
  # ── Logging Setup ──────────────────────────────────────────────────────
21
  logging.basicConfig(
 
34
  logger.info(f" LLM Model: {settings.llm_model}")
35
  logger.info(f" Vector DB: {settings.vector_db_type}")
36
 
37
+ # Initialize Firebase Admin SDK
38
+ init_firebase()
39
+
40
  # Pre-build knowledge base on startup (optional, lazy by default)
41
  # Uncomment the following to eagerly build on startup:
42
  # from backend.src.services.vector_service import build_pipeline
 
58
  lifespan=lifespan,
59
  )
60
 
61
+ from backend.src.api.limiter import limiter, _rate_limit_exceeded_handler, RateLimitExceeded
62
+ app.state.limiter = limiter
63
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
64
+
65
+
66
  # ── CORS Middleware ────────────────────────────────────────────────────
67
  app.add_middleware(
68
  CORSMiddleware,
 
77
  )
78
 
79
  # ── Routes ─────────────────────────────────────────────────────────────
80
+ app.include_router(chat.router)
81
+ app.include_router(progress.router)
82
 
83
 
84
  @app.get("/health")
src/math/knowledge_indexer.py CHANGED
@@ -1,290 +1,80 @@
1
  """
2
- Knowledge Base Indexing Pipeline — Data loading, preprocessing, and chunking.
3
-
4
- Extracted from main.py L80-L270. Contains:
5
- - MathDataLoader: loads NCERT knowledge base from knowledge_base.py
6
- - MathDataPreprocessor: cleans, deduplicates, detects topics
7
- - MathTextSplitter: splits documents into chunks for vector indexing
8
  """
9
 
10
- import re
11
- import hashlib
12
  import logging
13
- from typing import Dict, List
14
- from pathlib import Path
15
-
16
- logger = logging.getLogger("math_assistant.indexer")
17
-
18
- try:
19
- from langchain_core.documents import Document
20
- except ImportError:
21
- try:
22
- from langchain.schema import Document
23
- except ImportError:
24
- class Document:
25
- def __init__(self, page_content: str, metadata: dict = None):
26
- self.page_content = page_content
27
- self.metadata = metadata or {}
28
 
 
29
 
30
- class MathDataLoader:
31
- """Load NCERT math knowledge base from hardcoded knowledge_base.py."""
32
 
33
- def load_builtin_knowledge(self) -> List[Document]:
34
- """Load the hand-curated NCERT knowledge base.
35
-
36
- Returns:
37
- List of Document objects with topic metadata.
38
- """
39
- # Import from the project root knowledge_base.py
40
- import sys
41
- project_root = str(Path(__file__).resolve().parent.parent.parent.parent)
42
- if project_root not in sys.path:
43
- sys.path.insert(0, project_root)
44
 
45
- from knowledge_base import MATH_KNOWLEDGE_BASE, CLASS_EXAMPLES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  documents = []
47
-
48
- # Process topic documents
49
- for topic, sections in MATH_KNOWLEDGE_BASE.items():
50
- if isinstance(sections, dict):
51
- for sub_topic, content in sections.items():
52
- if isinstance(content, str) and len(content.strip()) > 50:
53
- documents.append(Document(
54
- page_content=content,
55
- metadata={"source": "builtin_kb", "topic": topic,
56
- "sub_topic": sub_topic}
57
- ))
58
- elif isinstance(content, dict):
59
- for k, v in content.items():
60
- if isinstance(v, str) and len(v.strip()) > 50:
61
- documents.append(Document(
62
- page_content=v,
63
- metadata={"source": "builtin_kb", "topic": topic,
64
- "sub_topic": f"{sub_topic}/{k}"}
65
- ))
66
- elif isinstance(sections, str) and len(sections.strip()) > 50:
67
- documents.append(Document(
68
- page_content=sections,
69
- metadata={"source": "builtin_kb", "topic": topic}
70
- ))
71
-
72
- # Process class examples
73
- for class_id, chapters in CLASS_EXAMPLES.items():
74
- if isinstance(chapters, dict):
75
- for chapter, questions in chapters.items():
76
- if isinstance(questions, list):
77
- for q in questions:
78
- if isinstance(q, dict):
79
- text = q.get("question", "") + "\n" + q.get("solution", "")
80
- if len(text.strip()) > 50:
81
- documents.append(Document(
82
- page_content=text,
83
- metadata={"source": "ncert_examples",
84
- "class": class_id,
85
- "chapter": chapter,
86
- "topic": q.get("topic", "general_math")}
87
- ))
88
- elif isinstance(questions, str) and len(questions.strip()) > 50:
89
- documents.append(Document(
90
- page_content=questions,
91
- metadata={"source": "ncert_examples",
92
- "class": class_id, "chapter": chapter}
93
- ))
94
-
95
- logger.info(f"Loaded {len(documents)} documents from built-in knowledge base")
96
- return documents
97
-
98
- def load_pdf(self, pdf_path: str) -> List[Document]:
99
- """Load a single PDF file."""
100
- try:
101
- from langchain_community.document_loaders import PyPDFLoader
102
- docs = PyPDFLoader(pdf_path).load()
103
- logger.info(f"Loaded {len(docs)} pages from: {pdf_path}")
104
- return docs
105
- except Exception as e:
106
- logger.error(f"Failed to load PDF {pdf_path}: {e}")
107
- return []
108
-
109
- def load_pdfs_from_directory(self, dir_path: str) -> List[Document]:
110
- """Load all PDFs from a directory."""
111
- try:
112
- from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
113
- docs = DirectoryLoader(dir_path, glob="**/*.pdf", loader_cls=PyPDFLoader).load()
114
- logger.info(f"Loaded {len(docs)} documents from: {dir_path}")
115
- return docs
116
- except Exception as e:
117
- logger.error(f"Failed to load PDFs from {dir_path}: {e}")
118
- return []
119
-
120
- def load_web_pages(self, urls: List[str]) -> List[Document]:
121
- """Load content from web URLs."""
122
- from langchain_community.document_loaders import WebBaseLoader
123
- docs = []
124
- for url in urls:
125
  try:
126
- loader = WebBaseLoader(url)
127
- loader.requests_kwargs = {"timeout": 15}
128
- docs.extend(loader.load())
129
- logger.info(f"Loaded: {url}")
 
 
 
 
130
  except Exception as e:
131
- logger.warning(f"Failed URL {url}: {e}")
132
- return docs
133
-
134
- def load_text_file(self, file_path: str) -> List[Document]:
135
- """Load a text or markdown file."""
136
- try:
137
- if file_path.endswith(".md"):
138
- from langchain_community.document_loaders import UnstructuredMarkdownLoader
139
- loader = UnstructuredMarkdownLoader(file_path)
140
- else:
141
- from langchain_community.document_loaders import TextLoader
142
- loader = TextLoader(file_path, encoding="utf-8")
143
- docs = loader.load()
144
- logger.info(f"Loaded: {file_path}")
145
- return docs
146
- except Exception as e:
147
- logger.error(f"Failed to load {file_path}: {e}")
148
- return []
149
-
150
- def load_all(self, pdf_paths=None, urls=None, text_paths=None, pdf_directory=None) -> List[Document]:
151
- """Load all data sources: builtin KB + PDFs + URLs + text files."""
152
- import os
153
- all_docs = self.load_builtin_knowledge()
154
- if pdf_paths:
155
- for p in pdf_paths:
156
- all_docs.extend(self.load_pdf(p))
157
- if pdf_directory and os.path.exists(pdf_directory):
158
- all_docs.extend(self.load_pdfs_from_directory(pdf_directory))
159
- if urls:
160
- all_docs.extend(self.load_web_pages(urls))
161
- if text_paths:
162
- for p in text_paths:
163
- all_docs.extend(self.load_text_file(p))
164
- logger.info(f"Total documents loaded: {len(all_docs)}")
165
- return all_docs
166
-
167
-
168
- class MathDataPreprocessor:
169
- """Clean, deduplicate, and enrich math documents with metadata."""
170
-
171
- TOPIC_KEYWORDS: Dict[str, List[str]] = {
172
- "calculus": ["derivative", "integral", "differentiate", "integrate", "limit", "continuity", "taylor"],
173
- "linear_algebra": ["matrix", "vector", "eigenvalue", "determinant", "rank", "span", "basis"],
174
- "statistics": ["probability", "distribution", "mean", "variance", "regression", "hypothesis"],
175
- "algebra": ["polynomial", "equation", "quadratic", "factor", "root", "logarithm", "exponent"],
176
- "trigonometry": ["sine", "cosine", "tangent", "angle", "radian", "unit circle", "trig"],
177
- "discrete_math": ["graph", "combinatorics", "permutation", "combination", "modular", "prime"],
178
- "geometry": ["triangle", "circle", "area", "volume", "perimeter", "pythagorean", "coordinate"],
179
- "number_theory": ["prime", "divisor", "gcd", "lcm", "modular", "congruence", "integer"],
180
- }
181
-
182
- def __init__(self):
183
- self._seen_hashes: set = set()
184
-
185
- def _clean(self, text: str) -> str:
186
- text = re.sub(r"\n{3,}", "\n\n", text)
187
- text = re.sub(r"[ \t]{2,}", " ", text)
188
- text = re.sub(r"Page\s+\d+\s+of\s+\d+", "", text, flags=re.IGNORECASE)
189
- text = re.sub(r"https?://\S+", "[URL]", text)
190
- text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
191
- replacements = {
192
- "\u2019": "'", "\u201c": '"', "\u201d": '"',
193
- "\u2013": "-", "\u2014": "--", "\u00a0": " ",
194
- "\u03c0": "pi", "\u221e": "infinity",
195
- "\u2264": "<=", "\u2265": ">="
196
- }
197
- for old, new in replacements.items():
198
- text = text.replace(old, new)
199
- return text.strip()
200
-
201
- def _detect_topic(self, text: str) -> str:
202
- tl = text.lower()
203
- scores = {t: sum(1 for kw in kws if kw in tl) for t, kws in self.TOPIC_KEYWORDS.items()}
204
- scores = {k: v for k, v in scores.items() if v > 0}
205
- return max(scores, key=scores.get) if scores else "general_math"
206
-
207
- def _difficulty(self, text: str) -> str:
208
- adv = ["eigenvalue", "differential equation", "fourier", "laplace", "manifold", "tensor"]
209
- mid = ["derivative", "integral", "matrix", "probability", "polynomial", "logarithm"]
210
- tl = text.lower()
211
- if sum(1 for t in adv if t in tl) >= 2: return "advanced"
212
- if sum(1 for t in mid if t in tl) >= 2: return "intermediate"
213
- return "beginner"
214
-
215
- def preprocess_document(self, doc):
216
- text = doc.page_content
217
- if len(text.strip()) < 50:
218
- return None
219
- text = self._clean(text)
220
- h = hashlib.md5(text.strip().lower().encode()).hexdigest()
221
- if h in self._seen_hashes:
222
- return None
223
- self._seen_hashes.add(h)
224
- meta = doc.metadata.copy()
225
- meta.update({
226
- "topic": meta.get("topic") or self._detect_topic(text),
227
- "difficulty": self._difficulty(text),
228
- "char_count": len(text),
229
- "word_count": len(text.split()),
230
- "content_hash": h,
231
- })
232
- return Document(page_content=text, metadata=meta)
233
-
234
- def preprocess_documents(self, documents):
235
- logger.info(f"Preprocessing {len(documents)} documents...")
236
- self._seen_hashes.clear()
237
- processed = [r for doc in documents if (r := self.preprocess_document(doc)) is not None]
238
- logger.info(f"Done: {len(processed)} kept, {len(documents)-len(processed)} skipped")
239
- return processed
240
-
241
-
242
- class MathTextSplitter:
243
- """Split documents into overlapping chunks optimized for math content."""
244
-
245
- def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):
246
- from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
247
- self.chunk_size = chunk_size
248
- self.chunk_overlap = chunk_overlap
249
- self._markdown_splitter = MarkdownHeaderTextSplitter(
250
- headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
251
- strip_headers=False,
252
- )
253
- self._text_splitter = RecursiveCharacterTextSplitter(
254
- chunk_size=chunk_size,
255
- chunk_overlap=chunk_overlap,
256
- separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
257
- )
258
-
259
- def split_document(self, doc) -> list:
260
- """Split a single document into chunks with metadata preserved."""
261
- try:
262
- md_chunks = self._markdown_splitter.split_text(doc.page_content)
263
- if len(md_chunks) > 1:
264
- chunks = []
265
- for mc in md_chunks:
266
- sub_chunks = self._text_splitter.split_text(mc.page_content)
267
- for sc in sub_chunks:
268
- meta = {**doc.metadata, **mc.metadata}
269
- chunks.append(Document(page_content=sc, metadata=meta))
270
- final = chunks
271
- else:
272
- texts = self._text_splitter.split_text(doc.page_content)
273
- final = [Document(page_content=t, metadata=doc.metadata.copy()) for t in texts]
274
- except Exception:
275
- texts = self._text_splitter.split_text(doc.page_content)
276
- final = [Document(page_content=t, metadata=doc.metadata.copy()) for t in texts]
277
-
278
- for i, chunk in enumerate(final):
279
- chunk.metadata["chunk_index"] = i
280
- chunk.metadata["total_chunks"] = len(final)
281
-
282
- return final
283
 
284
- def split_documents(self, documents) -> list:
285
- """Split multiple documents and flatten into a single list."""
286
- all_chunks = []
287
- for doc in documents:
288
- all_chunks.extend(self.split_document(doc))
289
- logger.info(f"Split {len(documents)} documents into {len(all_chunks)} chunks")
290
- return all_chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Knowledge Base Indexing Pipeline — Markdown loading, preprocessing, and chunking for Qdrant.
 
 
 
 
 
3
  """
4
 
5
+ import os
6
+ import glob
7
  import logging
8
+ from typing import List
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ from langchain_core.documents import Document
11
 
12
+ from backend.src.services.qdrant_service import QdrantService
13
+ from backend.src.config import settings
14
 
15
+ logger = logging.getLogger("math_assistant.indexer")
 
 
 
 
 
 
 
 
 
 
16
 
17
+ class KnowledgeIndexer:
18
+ """Parses local markdown files and indexes them into the Qdrant vector store."""
19
+
20
+ def __init__(self, kb_dir: str = "knowledge-base"):
21
+ self.kb_dir = kb_dir
22
+ self.qdrant = QdrantService()
23
+
24
+ def parse_frontmatter(self, text: str) -> tuple[dict, str]:
25
+ """Extract metadata from markdown frontmatter."""
26
+ metadata = {}
27
+ content = text
28
+ if text.startswith("---\n"):
29
+ parts = text.split("---\n", 2)
30
+ if len(parts) == 3:
31
+ frontmatter = parts[1]
32
+ content = parts[2]
33
+ for line in frontmatter.strip().split('\n'):
34
+ if ':' in line:
35
+ key, val = line.split(':', 1)
36
+ metadata[key.strip()] = val.strip()
37
+ return metadata, content.strip()
38
+
39
+ def load_markdown_files(self) -> List[Document]:
40
+ """Load all markdown files from the knowledge base directory."""
41
  documents = []
42
+ search_pattern = os.path.join(self.kb_dir, "**", "*.md")
43
+ filepaths = glob.glob(search_pattern, recursive=True)
44
+
45
+ logger.info(f"Found {len(filepaths)} markdown files in {self.kb_dir}")
46
+
47
+ for filepath in filepaths:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  try:
49
+ with open(filepath, 'r', encoding='utf-8') as f:
50
+ text = f.read()
51
+
52
+ metadata, content = self.parse_frontmatter(text)
53
+ metadata["source_file"] = filepath
54
+
55
+ doc = Document(page_content=content, metadata=metadata)
56
+ documents.append(doc)
57
  except Exception as e:
58
+ logger.error(f"Failed to process {filepath}: {e}")
59
+
60
+ return documents
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
+ def index_all(self):
63
+ """Load all files, split, and index into Qdrant."""
64
+ logger.info("Starting Knowledge Base indexing...")
65
+ docs = self.load_markdown_files()
66
+ if not docs:
67
+ logger.warning("No documents found to index.")
68
+ return
69
+
70
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
71
+ splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
72
+ chunks = splitter.split_documents(docs)
73
+
74
+ logger.info(f"Created {len(chunks)} chunks. Pushing to Qdrant...")
75
+ self.qdrant.add_documents(chunks)
76
+ logger.info(f"Indexing complete. Total vectors: {self.qdrant.get_document_count()}")
77
+
78
+ if __name__ == "__main__":
79
+ indexer = KnowledgeIndexer()
80
+ indexer.index_all()
src/services/embedding_service.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding Service — Manages dense and sparse embedding models.
3
+ """
4
+
5
+ import logging
6
+ from backend.src.config import settings
7
+
8
+ logger = logging.getLogger("math_assistant.embeddings")
9
+
10
+ _DENSE_EMBEDDINGS_CACHE = None
11
+
12
+ def get_dense_embeddings():
13
+ """Get or create the singleton dense embedding model (text-embedding-004)."""
14
+ global _DENSE_EMBEDDINGS_CACHE
15
+ if _DENSE_EMBEDDINGS_CACHE is None:
16
+ try:
17
+ # First try new gemini integration
18
+ from langchain_google_genai import GoogleGenerativeAIEmbeddings
19
+ logger.info("Loading dense embedding model: text-embedding-004")
20
+ _DENSE_EMBEDDINGS_CACHE = GoogleGenerativeAIEmbeddings(
21
+ model="models/text-embedding-004",
22
+ task_type="retrieval_document"
23
+ )
24
+ except Exception as e:
25
+ logger.warning(f"Failed to load Google embeddings ({e}), falling back to HuggingFace")
26
+ from langchain_huggingface import HuggingFaceEmbeddings
27
+ _DENSE_EMBEDDINGS_CACHE = HuggingFaceEmbeddings(
28
+ model_name=settings.embedding_model,
29
+ model_kwargs={"device": "cpu"},
30
+ encode_kwargs={"normalize_embeddings": True},
31
+ )
32
+ return _DENSE_EMBEDDINGS_CACHE
src/services/evaluation_service.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluation Service — Offline LLM-as-judge pipeline for answering quality.
3
+ """
4
+
5
+ import logging
6
+ from typing import Dict, List, Any
7
+ from backend.src.config import settings
8
+
9
+ logger = logging.getLogger("math_assistant.eval")
10
+
11
+ class EvaluationService:
12
+ """Uses LLM-as-judge to evaluate generated math answers against reference solutions."""
13
+
14
+ def __init__(self, judge_model: str = "gemini-2.5-flash"):
15
+ self.judge_model = judge_model
16
+
17
+ def evaluate_answer(self, question: str, generated_answer: str, reference_answer: str) -> Dict[str, Any]:
18
+ """Ask the judge model to score the generated answer from 1 to 5."""
19
+ from google import genai
20
+ from google.genai import types
21
+
22
+ api_key = settings.gemini_api_key
23
+ if not api_key:
24
+ return {"score": 0, "reasoning": "Missing API Key for evaluation"}
25
+
26
+ client = genai.Client(api_key=api_key)
27
+
28
+ prompt = f"""You are an expert mathematics teacher grading a student's answer.
29
+
30
+ Question: {question}
31
+
32
+ Reference/Correct Answer:
33
+ {reference_answer}
34
+
35
+ Student's Answer to evaluate:
36
+ {generated_answer}
37
+
38
+ Grade the student's answer from 1 to 5 based on:
39
+ 1. Accuracy: Is the final answer correct?
40
+ 2. Steps: Are the steps logical and mathematically sound?
41
+
42
+ Format your response EXACTLY as follows:
43
+ SCORE: [1-5]
44
+ REASONING: [1-2 sentences explaining why]
45
+ """
46
+
47
+ config = types.GenerateContentConfig(
48
+ temperature=0.1,
49
+ max_output_tokens=150,
50
+ )
51
+
52
+ try:
53
+ response = client.models.generate_content(
54
+ model=self.judge_model,
55
+ contents=prompt,
56
+ config=config
57
+ )
58
+
59
+ text = response.text.strip()
60
+ score = 0
61
+ reasoning = text
62
+
63
+ for line in text.split('\n'):
64
+ if line.startswith("SCORE:"):
65
+ try:
66
+ score = int(line.replace("SCORE:", "").strip())
67
+ except:
68
+ pass
69
+ elif line.startswith("REASONING:"):
70
+ reasoning = line.replace("REASONING:", "").strip()
71
+
72
+ return {"score": score, "reasoning": reasoning}
73
+
74
+ except Exception as e:
75
+ logger.error(f"Evaluation failed: {e}")
76
+ return {"score": 0, "reasoning": f"Evaluation error: {e}"}
77
+
78
+ def run_eval_pipeline(self, test_cases: List[Dict[str, str]]) -> Dict[str, Any]:
79
+ """Run evaluation on a batch of test cases."""
80
+ results = []
81
+ total_score = 0
82
+
83
+ for case in test_cases:
84
+ res = self.evaluate_answer(
85
+ question=case['question'],
86
+ generated_answer=case['generated_answer'],
87
+ reference_answer=case['reference_answer']
88
+ )
89
+ total_score += res['score']
90
+ results.append({
91
+ "question": case['question'],
92
+ "score": res['score'],
93
+ "reasoning": res['reasoning']
94
+ })
95
+
96
+ avg_score = total_score / len(test_cases) if test_cases else 0
97
+ return {
98
+ "average_score": avg_score,
99
+ "total_evaluated": len(test_cases),
100
+ "results": results
101
+ }
src/services/firebase_service.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Firebase Service — Initializes Firebase Admin SDK and provides database access.
3
+
4
+ This replaces MongoDB for database persistence in Phase 3.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from backend.src.config import settings
12
+
13
+ logger = logging.getLogger("math_assistant.firebase")
14
+
15
+ _FIREBASE_APP = None
16
+ _FIRESTORE_CLIENT = None
17
+
18
+ def init_firebase():
19
+ """Initialize the Firebase Admin SDK."""
20
+ global _FIREBASE_APP, _FIRESTORE_CLIENT
21
+
22
+ if not settings.use_firebase:
23
+ logger.info("Firebase integration disabled in config.")
24
+ return
25
+
26
+ if _FIREBASE_APP is not None:
27
+ return
28
+
29
+ import firebase_admin
30
+ from firebase_admin import credentials
31
+ from firebase_admin import firestore
32
+
33
+ cred_path = settings.firebase_credentials_path
34
+ if not cred_path or not os.path.exists(cred_path):
35
+ logger.warning(
36
+ f"Firebase credentials not found at {cred_path}. "
37
+ "Running in unauthenticated/memory-fallback mode."
38
+ )
39
+ return
40
+
41
+ try:
42
+ cred = credentials.Certificate(cred_path)
43
+ _FIREBASE_APP = firebase_admin.initialize_app(cred)
44
+ _FIRESTORE_CLIENT = firestore.client()
45
+ logger.info("Firebase Admin SDK initialized successfully.")
46
+ except Exception as e:
47
+ logger.error(f"Failed to initialize Firebase Admin SDK: {e}")
48
+
49
+ def get_firestore_client():
50
+ """Get the initialized Firestore client."""
51
+ return _FIRESTORE_CLIENT
52
+
53
+ def get_auth():
54
+ """Get the Firebase Auth module."""
55
+ if _FIREBASE_APP is None:
56
+ return None
57
+ from firebase_admin import auth
58
+ return auth
59
+
60
+ def save_user_feedback(uid: str, session_id: str, message_id: str, is_positive: bool, comments: str = ""):
61
+ """Save explicit human feedback to Firestore."""
62
+ db = get_firestore_client()
63
+ if not db:
64
+ logger.warning("Cannot save feedback: Firebase is disabled or not initialized.")
65
+ return
66
+
67
+ try:
68
+ from datetime import datetime, timezone
69
+
70
+ feedback_data = {
71
+ "session_id": session_id,
72
+ "message_id": message_id,
73
+ "is_positive": is_positive,
74
+ "comments": comments,
75
+ "timestamp": datetime.now(timezone.utc)
76
+ }
77
+
78
+ db.collection("users").document(uid).collection("feedback").add(feedback_data)
79
+ logger.info(f"Feedback saved for user {uid}")
80
+ except Exception as e:
81
+ logger.error(f"Failed to save user feedback: {e}")
src/services/gemini_service.py CHANGED
@@ -7,47 +7,54 @@ Provides:
7
  - Streaming support for token-by-token SSE delivery
8
  - Automatic fallback from Gemini 2.0 Flash → Gemini 1.5 Flash on rate limits
9
 
10
- Uses Google AI Studio free tier (google-generativeai SDK).
11
  """
12
 
13
- import re
14
  import time
15
  import logging
16
- import base64
17
  from typing import Dict, Any, Optional, AsyncGenerator
18
 
19
- from backend.src.config import settings, SYSTEM_TEMPLATE
 
20
 
21
  logger = logging.getLogger("math_assistant.gemini")
22
 
23
- _GEMINI_CACHE = {}
24
 
25
-
26
- def _get_gemini_model(model_name: str = None):
27
- """Get or create a cached Gemini GenerativeModel instance.
28
-
29
- Uses google-generativeai SDK (Google AI Studio free tier).
30
- """
31
- import google.generativeai as genai
32
-
33
- model_name = model_name or settings.gemini_primary_model
34
- if model_name not in _GEMINI_CACHE:
35
  api_key = settings.gemini_api_key
36
  if not api_key:
37
  raise ValueError(
38
  "GEMINI_API_KEY not set. Get a free key at https://aistudio.google.com/apikey "
39
  "and add it to your .env file."
40
  )
41
- genai.configure(api_key=api_key)
42
- _GEMINI_CACHE[model_name] = genai.GenerativeModel(
43
- model_name=model_name,
44
- generation_config=genai.GenerationConfig(
45
- temperature=settings.gemini_temperature,
46
- max_output_tokens=settings.gemini_max_tokens,
47
- ),
48
- )
49
- logger.info(f"Initialized Gemini model: {model_name}")
50
- return _GEMINI_CACHE[model_name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  class GeminiService:
@@ -56,19 +63,14 @@ class GeminiService:
56
  def __init__(self):
57
  self.primary_model = settings.gemini_primary_model
58
  self.fallback_model = settings.gemini_fallback_model
 
59
 
60
  def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
61
- """Send a math query to Gemini and return the response.
62
-
63
- Args:
64
- user_input: The student's math question.
65
- context: RAG-retrieved knowledge base context.
66
- chat_history: List of previous messages for context.
67
-
68
- Returns:
69
- The LLM response string.
70
- """
71
- system_text = SYSTEM_TEMPLATE.replace("{context}", context or "")
72
 
73
  # Build conversation parts
74
  parts = [system_text + "\n\n"]
@@ -82,15 +84,24 @@ class GeminiService:
82
 
83
  prompt = "".join(parts)
84
 
85
- # Try primary model, then fallback
86
  models_to_try = [self.primary_model, self.fallback_model]
87
  last_error = None
 
 
 
 
 
 
 
88
 
89
  for model_name in models_to_try:
90
- model = _get_gemini_model(model_name)
91
  for attempt in range(2):
92
  try:
93
- response = model.generate_content(prompt)
 
 
 
 
94
 
95
  if response.text:
96
  answer = response.text.strip()
@@ -98,10 +109,7 @@ class GeminiService:
98
  answer = f"*(Using fallback: {model_name})*\n\n" + answer
99
  return answer
100
  else:
101
- # Check for blocked content
102
- if response.prompt_feedback and response.prompt_feedback.block_reason:
103
- raise Exception(f"Content blocked: {response.prompt_feedback.block_reason}")
104
- raise Exception("Empty response from Gemini")
105
 
106
  except Exception as e:
107
  last_error = e
@@ -118,16 +126,16 @@ class GeminiService:
118
  else:
119
  break # Non-retryable
120
 
121
- # All models failed
122
  logger.error(f"All Gemini models failed: {last_error}")
123
  return f"⚠️ Gemini API error: {last_error}"
124
 
125
  async def stream(self, user_input: str, context: str = "", chat_history: list = None) -> AsyncGenerator[str, None]:
126
- """Stream a response token by token for SSE delivery.
127
-
128
- Yields chunks of text as they arrive from Gemini's streaming API.
129
- """
130
- system_text = SYSTEM_TEMPLATE.replace("{context}", context or "")
 
131
  parts = [system_text + "\n\n"]
132
  if chat_history:
133
  for msg in chat_history:
@@ -138,10 +146,20 @@ class GeminiService:
138
  parts.append(f"Student: {user_input}\nTeacher: ")
139
  prompt = "".join(parts)
140
 
141
- model = _get_gemini_model(self.primary_model)
 
 
 
 
 
 
142
  try:
143
- response = model.generate_content(prompt, stream=True)
144
- for chunk in response:
 
 
 
 
145
  if chunk.text:
146
  yield chunk.text
147
  except Exception as e:
@@ -150,24 +168,14 @@ class GeminiService:
150
 
151
 
152
  class GeminiVisionService:
153
- """Gemini Vision for extracting math from images (replaces Tesseract OCR)."""
154
 
155
  def __init__(self):
156
  self.model_name = settings.gemini_vision_model
157
 
158
  def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
159
- """Extract mathematical content from an image using Gemini Vision.
160
-
161
- Args:
162
- image_bytes: Raw image bytes.
163
- mime_type: Image MIME type (image/jpeg, image/png, etc.).
164
-
165
- Returns:
166
- Extracted mathematical expression or problem text.
167
- """
168
- import google.generativeai as genai
169
-
170
- model = _get_gemini_model(self.model_name)
171
 
172
  prompt = (
173
  "You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
@@ -180,11 +188,16 @@ class GeminiVisionService:
180
  "6. Return ONLY the extracted math — no commentary.\n"
181
  )
182
 
 
183
  try:
184
- response = model.generate_content([
185
- prompt,
186
- {"mime_type": mime_type, "data": image_bytes},
187
- ])
 
 
 
 
188
  if response.text:
189
  return response.text.strip()
190
  return "Could not extract math from image."
@@ -193,13 +206,8 @@ class GeminiVisionService:
193
  return f"Vision error: {e}"
194
 
195
  def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
196
- """Extract math from image and solve it in one call.
197
-
198
- Returns dict with 'extracted' (the raw math) and 'solution' (step-by-step).
199
- """
200
- import google.generativeai as genai
201
-
202
- model = _get_gemini_model(self.model_name)
203
 
204
  prompt = (
205
  "You are an Indian math teacher. Look at this image.\n\n"
@@ -211,14 +219,18 @@ class GeminiVisionService:
211
  "SOLUTION:\n[step-by-step solution]\n"
212
  )
213
 
 
214
  try:
215
- response = model.generate_content([
216
- prompt,
217
- {"mime_type": mime_type, "data": image_bytes},
218
- ])
 
 
 
 
219
  text = response.text.strip() if response.text else ""
220
 
221
- # Parse response
222
  if "EXTRACTED:" in text and "SOLUTION:" in text:
223
  parts = text.split("---", 1)
224
  extracted = parts[0].replace("EXTRACTED:", "").strip()
 
7
  - Streaming support for token-by-token SSE delivery
8
  - Automatic fallback from Gemini 2.0 Flash → Gemini 1.5 Flash on rate limits
9
 
10
+ Uses Google AI Studio free tier via the google-genai SDK.
11
  """
12
 
 
13
  import time
14
  import logging
 
15
  from typing import Dict, Any, Optional, AsyncGenerator
16
 
17
+ from backend.src.config import settings
18
+ from backend.src.services.prompt_service import PromptService
19
 
20
  logger = logging.getLogger("math_assistant.gemini")
21
 
22
+ _GEMINI_CLIENT = None
23
 
24
+ def _get_gemini_client():
25
+ global _GEMINI_CLIENT
26
+ if _GEMINI_CLIENT is None:
27
+ from google import genai
 
 
 
 
 
 
28
  api_key = settings.gemini_api_key
29
  if not api_key:
30
  raise ValueError(
31
  "GEMINI_API_KEY not set. Get a free key at https://aistudio.google.com/apikey "
32
  "and add it to your .env file."
33
  )
34
+ _GEMINI_CLIENT = genai.Client(api_key=api_key)
35
+ logger.info("Initialized google-genai Client")
36
+ return _GEMINI_CLIENT
37
+
38
+ def _get_safety_settings():
39
+ from google.genai import types
40
+ return [
41
+ types.SafetySetting(
42
+ category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
43
+ threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
44
+ ),
45
+ types.SafetySetting(
46
+ category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
47
+ threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
48
+ ),
49
+ types.SafetySetting(
50
+ category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
51
+ threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
52
+ ),
53
+ types.SafetySetting(
54
+ category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
55
+ threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
56
+ ),
57
+ ]
58
 
59
 
60
  class GeminiService:
 
63
  def __init__(self):
64
  self.primary_model = settings.gemini_primary_model
65
  self.fallback_model = settings.gemini_fallback_model
66
+ self.prompt_service = PromptService()
67
 
68
  def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
69
+ """Send a math query to Gemini and return the response."""
70
+ from google.genai import types
71
+
72
+ system_template = self.prompt_service.get_system_prompt()
73
+ system_text = system_template.replace("{context}", context or "")
 
 
 
 
 
 
74
 
75
  # Build conversation parts
76
  parts = [system_text + "\n\n"]
 
84
 
85
  prompt = "".join(parts)
86
 
 
87
  models_to_try = [self.primary_model, self.fallback_model]
88
  last_error = None
89
+ client = _get_gemini_client()
90
+
91
+ config = types.GenerateContentConfig(
92
+ temperature=settings.gemini_temperature,
93
+ max_output_tokens=settings.gemini_max_tokens,
94
+ safety_settings=_get_safety_settings()
95
+ )
96
 
97
  for model_name in models_to_try:
 
98
  for attempt in range(2):
99
  try:
100
+ response = client.models.generate_content(
101
+ model=model_name,
102
+ contents=prompt,
103
+ config=config
104
+ )
105
 
106
  if response.text:
107
  answer = response.text.strip()
 
109
  answer = f"*(Using fallback: {model_name})*\n\n" + answer
110
  return answer
111
  else:
112
+ raise Exception("Empty response or content blocked by safety filters.")
 
 
 
113
 
114
  except Exception as e:
115
  last_error = e
 
126
  else:
127
  break # Non-retryable
128
 
 
129
  logger.error(f"All Gemini models failed: {last_error}")
130
  return f"⚠️ Gemini API error: {last_error}"
131
 
132
  async def stream(self, user_input: str, context: str = "", chat_history: list = None) -> AsyncGenerator[str, None]:
133
+ """Stream a response token by token for SSE delivery."""
134
+ from google.genai import types
135
+
136
+ system_template = self.prompt_service.get_system_prompt()
137
+ system_text = system_template.replace("{context}", context or "")
138
+
139
  parts = [system_text + "\n\n"]
140
  if chat_history:
141
  for msg in chat_history:
 
146
  parts.append(f"Student: {user_input}\nTeacher: ")
147
  prompt = "".join(parts)
148
 
149
+ client = _get_gemini_client()
150
+ config = types.GenerateContentConfig(
151
+ temperature=settings.gemini_temperature,
152
+ max_output_tokens=settings.gemini_max_tokens,
153
+ safety_settings=_get_safety_settings()
154
+ )
155
+
156
  try:
157
+ response_stream = client.models.generate_content_stream(
158
+ model=self.primary_model,
159
+ contents=prompt,
160
+ config=config
161
+ )
162
+ for chunk in response_stream:
163
  if chunk.text:
164
  yield chunk.text
165
  except Exception as e:
 
168
 
169
 
170
  class GeminiVisionService:
171
+ """Gemini Vision for extracting math from images."""
172
 
173
  def __init__(self):
174
  self.model_name = settings.gemini_vision_model
175
 
176
  def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
177
+ from google.genai import types
178
+ client = _get_gemini_client()
 
 
 
 
 
 
 
 
 
 
179
 
180
  prompt = (
181
  "You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
 
188
  "6. Return ONLY the extracted math — no commentary.\n"
189
  )
190
 
191
+ config = types.GenerateContentConfig(safety_settings=_get_safety_settings())
192
  try:
193
+ response = client.models.generate_content(
194
+ model=self.model_name,
195
+ contents=[
196
+ prompt,
197
+ types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
198
+ ],
199
+ config=config
200
+ )
201
  if response.text:
202
  return response.text.strip()
203
  return "Could not extract math from image."
 
206
  return f"Vision error: {e}"
207
 
208
  def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
209
+ from google.genai import types
210
+ client = _get_gemini_client()
 
 
 
 
 
211
 
212
  prompt = (
213
  "You are an Indian math teacher. Look at this image.\n\n"
 
219
  "SOLUTION:\n[step-by-step solution]\n"
220
  )
221
 
222
+ config = types.GenerateContentConfig(safety_settings=_get_safety_settings())
223
  try:
224
+ response = client.models.generate_content(
225
+ model=self.model_name,
226
+ contents=[
227
+ prompt,
228
+ types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
229
+ ],
230
+ config=config
231
+ )
232
  text = response.text.strip() if response.text else ""
233
 
 
234
  if "EXTRACTED:" in text and "SOLUTION:" in text:
235
  parts = text.split("---", 1)
236
  extracted = parts[0].replace("EXTRACTED:", "").strip()
src/services/memory_service.py CHANGED
@@ -1,8 +1,9 @@
1
  """
2
- Chat Memory Service — MongoDB-backed persistent chat history.
3
 
4
- Extracted from main.py L525-L582. Provides per-session chat history
5
- with MongoDB persistence and in-memory fallback when MongoDB is unavailable.
 
6
  """
7
 
8
  import logging
@@ -10,6 +11,7 @@ from datetime import datetime, timezone
10
  from typing import List, Dict
11
 
12
  from backend.src.config import settings
 
13
 
14
  try:
15
  from langchain_core.messages import HumanMessage, AIMessage
@@ -19,56 +21,59 @@ except ImportError:
19
  logger = logging.getLogger("math_assistant.memory")
20
 
21
 
22
- class MongoDBChatMemory:
23
- """Per-session chat memory with MongoDB persistence and in-memory fallback."""
24
 
25
- def __init__(self, session_id: str = "default"):
 
26
  self.session_id = session_id
27
- self.collection = None
28
  self._memory: List[Dict] = []
29
- self._connect()
30
-
31
- def _connect(self):
32
- if not settings.mongodb_uri:
33
- return
34
- try:
35
- from pymongo import MongoClient
36
- client = MongoClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000)
37
- client.admin.command("ping")
38
- self.collection = client[settings.mongodb_db_name][settings.mongodb_collection]
39
- logger.info("MongoDB connected")
40
- except Exception as e:
41
- logger.warning(f"MongoDB unavailable ({e}), using in-memory history")
42
 
43
  def add_message(self, role: str, content: str):
44
  """Add a message to chat history."""
45
  msg = {
46
- "session_id": self.session_id,
47
  "role": role,
48
  "content": content,
49
  "timestamp": datetime.now(timezone.utc),
50
  }
51
- if self.collection is not None:
 
 
52
  try:
53
- self.collection.insert_one(msg)
54
  return
55
- except Exception:
56
- pass
 
 
 
57
  self._memory.append(msg)
58
 
59
  def get_history(self, limit: int = 20) -> List[Dict]:
60
  """Retrieve recent chat history for this session."""
61
- if self.collection is not None:
 
62
  try:
63
- msgs = list(
64
- self.collection.find({"session_id": self.session_id})
65
- .sort("timestamp", -1)
66
  .limit(limit)
 
67
  )
 
68
  msgs.reverse()
69
  return msgs
70
- except Exception:
71
- pass
 
 
72
  return self._memory[-limit:]
73
 
74
  def get_langchain_messages(self, limit: int = 10):
@@ -76,17 +81,29 @@ class MongoDBChatMemory:
76
  history = self.get_history(limit)
77
  result = []
78
  for msg in history:
79
- if msg["role"] == "human":
80
- result.append(HumanMessage(content=msg["content"]))
81
- elif msg["role"] == "assistant":
82
- result.append(AIMessage(content=msg["content"]))
83
  return result
84
 
85
  def clear_history(self):
86
  """Clear all messages for this session."""
87
- if self.collection is not None:
 
88
  try:
89
- self.collection.delete_many({"session_id": self.session_id})
90
- except Exception:
91
- pass
 
 
 
 
 
 
 
 
92
  self._memory.clear()
 
 
 
 
1
  """
2
+ Chat Memory Service — Firestore-backed persistent chat history.
3
 
4
+ Provides per-session chat history with Firestore persistence and
5
+ in-memory fallback when Firestore is unavailable.
6
+ Replaces the MongoDB implementation from Phase 1.
7
  """
8
 
9
  import logging
 
11
  from typing import List, Dict
12
 
13
  from backend.src.config import settings
14
+ from backend.src.services.firebase_service import get_firestore_client
15
 
16
  try:
17
  from langchain_core.messages import HumanMessage, AIMessage
 
21
  logger = logging.getLogger("math_assistant.memory")
22
 
23
 
24
+ class FirestoreChatMemory:
25
+ """Per-session chat memory with Firestore persistence and in-memory fallback."""
26
 
27
+ def __init__(self, uid: str = "anonymous", session_id: str = "default"):
28
+ self.uid = uid
29
  self.session_id = session_id
 
30
  self._memory: List[Dict] = []
31
+ self.db = get_firestore_client()
32
+
33
+ def _get_collection_ref(self):
34
+ """Get a reference to the session's messages subcollection."""
35
+ if not self.db:
36
+ return None
37
+ return self.db.collection("users").document(self.uid).collection("sessions").document(self.session_id).collection("messages")
 
 
 
 
 
 
38
 
39
  def add_message(self, role: str, content: str):
40
  """Add a message to chat history."""
41
  msg = {
 
42
  "role": role,
43
  "content": content,
44
  "timestamp": datetime.now(timezone.utc),
45
  }
46
+
47
+ col_ref = self._get_collection_ref()
48
+ if col_ref is not None:
49
  try:
50
+ col_ref.add(msg)
51
  return
52
+ except Exception as e:
53
+ logger.error(f"Failed to add message to Firestore: {e}")
54
+
55
+ # Fallback to in-memory
56
+ msg["session_id"] = self.session_id
57
  self._memory.append(msg)
58
 
59
  def get_history(self, limit: int = 20) -> List[Dict]:
60
  """Retrieve recent chat history for this session."""
61
+ col_ref = self._get_collection_ref()
62
+ if col_ref is not None:
63
  try:
64
+ from firebase_admin import firestore
65
+ docs = (
66
+ col_ref.order_by("timestamp", direction=firestore.Query.DESCENDING)
67
  .limit(limit)
68
+ .stream()
69
  )
70
+ msgs = [doc.to_dict() for doc in docs]
71
  msgs.reverse()
72
  return msgs
73
+ except Exception as e:
74
+ logger.error(f"Failed to get history from Firestore: {e}")
75
+
76
+ # Fallback to in-memory
77
  return self._memory[-limit:]
78
 
79
  def get_langchain_messages(self, limit: int = 10):
 
81
  history = self.get_history(limit)
82
  result = []
83
  for msg in history:
84
+ if msg.get("role") == "human":
85
+ result.append(HumanMessage(content=msg.get("content", "")))
86
+ elif msg.get("role") == "assistant":
87
+ result.append(AIMessage(content=msg.get("content", "")))
88
  return result
89
 
90
  def clear_history(self):
91
  """Clear all messages for this session."""
92
+ col_ref = self._get_collection_ref()
93
+ if col_ref is not None:
94
  try:
95
+ docs = col_ref.limit(500).stream()
96
+ batch = self.db.batch()
97
+ count = 0
98
+ for doc in docs:
99
+ batch.delete(doc.reference)
100
+ count += 1
101
+ if count > 0:
102
+ batch.commit()
103
+ except Exception as e:
104
+ logger.error(f"Failed to clear history in Firestore: {e}")
105
+
106
  self._memory.clear()
107
+
108
+ # Maintain backward compatibility name for the MathAIEngine import during transition
109
+ MongoDBChatMemory = FirestoreChatMemory
src/services/prompt_service.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from google.cloud import firestore
3
+ from backend.src.config import settings
4
+
5
+ logger = logging.getLogger("math_assistant.prompt")
6
+
7
+ DEFAULT_SYSTEM_PROMPT = """You are an Indian mathematics teacher writing on a whiteboard.
8
+
9
+ ⚠️ NON-MATH QUESTIONS:
10
+ If NOT about mathematics → reply ONLY: "❌ I only teach math! Ask me any math problem."
11
+ Stop immediately. Nothing else.
12
+
13
+ ════════════════════════════════════════
14
+ THE GOLDEN RULE — READ THIS FIRST:
15
+ ════════════════════════════════════════
16
+
17
+ NEVER write paragraphs. NEVER write long sentences explaining theory.
18
+ Write SHORT lines. Like a teacher writing on a board.
19
+ Every line = one idea. One calculation. One small result.
20
+ If a student can't follow in 5 seconds → you wrote too much.
21
+
22
+ WRONG (too much theory, paragraph style):
23
+ "The Commutative Property of Addition states that when we add numbers,
24
+ the order does not matter. This means that 3+4 gives the same result
25
+ as 4+3, which we can verify by counting on a number line..."
26
+
27
+ RIGHT (whiteboard style):
28
+ Step 1 — Check: does order matter in addition?
29
+ 3 + 4 = 7
30
+ 4 + 3 = 7 ← same answer!
31
+ ✓ Yes — order doesn't matter. This is called Commutative Property.
32
+
33
+ ════════════════════════════════════════
34
+ FORMAT — FOLLOW EXACTLY EVERY TIME:
35
+ ════════════════════════════════════════
36
+
37
+ [One short opening — max 1 line. Like reading the problem aloud.]
38
+ "Okay, quadratic equation. Let's use the formula."
39
+ "Right — we need HCF of two numbers."
40
+ "Alright, let's integrate this step by step."
41
+
42
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━
43
+ Question: [restate question clearly]
44
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━
45
+
46
+ Step 1 — [title: what and why, max 1 line]
47
+ [calculation line 1]
48
+ [calculation line 2]
49
+ [short teacher note if needed — max 1 line]
50
+
51
+ Step 2 — [title]
52
+ [calculation]
53
+ [result]
54
+
55
+ [only as many steps as needed — no fake steps]
56
+
57
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━
58
+ ✅ Answer: [final answer]
59
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━
60
+
61
+ [One closing line max — "Key thing: watch the sign here!" or "Make sense?"]
62
+
63
+ ════════════════════════════════════════
64
+ INSIDE EACH STEP — RULES:
65
+ ════════════════════════════════════════
66
+
67
+ ✅ DO write like this:
68
+ a = 2, b = 5, c = -3
69
+ b² - 4ac = 25 - 4(2)(-3) = 25 + 24 = 49 ← careful: minus×minus = plus!
70
+ √49 = 7 ← clean number, good sign!
71
+ x = (-5 ± 7) / 4
72
+
73
+ ✅ DO add ONE short teacher reaction inline:
74
+ "← careful here" "← minus × minus = plus!" "← nice, simplifies!"
75
+ "← most students miss this" "← remember this!"
76
+
77
+ ❌ NEVER write:
78
+ - Paragraphs or long sentences
79
+ - Theory blocks explaining what a property IS
80
+ - Repeated explanations of the same idea
81
+ - More than 1 line of teacher commentary per step
82
+ - Sentences like "In this case, we can see that..." or "This demonstrates..."
83
+ - Definitions ("The quadratic formula is used when...")
84
+ - History or background ("This property was discovered...")
85
+
86
+ ════════════════════════════════════════
87
+ DETECT LEVEL — CHANGE DEPTH NOT STYLE:
88
+ ════════════════════════════════════════
89
+
90
+ Class 1–5:
91
+ → Ultra simple. Real objects. ("3 apples + 4 apples = 7 apples")
92
+ → No jargon. Max 3 steps.
93
+ → Lots of ✓ and encouragement inline.
94
+
95
+ Class 6–8:
96
+ → Short friendly lines. Explain WHY in 3-4 words inline.
97
+ → "← because negative × negative = positive"
98
+
99
+ Class 9–10:
100
+ → Full working, every line shown.
101
+ → One inline note on common exam mistake.
102
+ → "← board exams always ask this"
103
+
104
+ Class 11–12:
105
+ → State theorem/formula name once, then just use it.
106
+ → Show every substitution clearly.
107
+
108
+ JEE Advanced:
109
+ → Full clean solution first.
110
+ → Then add:
111
+ 💡 Key Insight: [one line — the clever observation]
112
+ ⏱️ Exam Tip: [one line — what to write quickly]
113
+
114
+ ════════════════════════════════════════
115
+ SYMBOLS — STRICT:
116
+ ════════════════════════════════════════
117
+
118
+ → Use LaTeX math mode strictly for all math: `$...$` for inline, `$$...$$` for block.
119
+ → Hindi/Hinglish question → answer in same language
120
+
121
+ Context from knowledge base:
122
+ {context}
123
+ """
124
+
125
+ class PromptService:
126
+ def __init__(self):
127
+ self.db = None
128
+ if settings.use_firebase:
129
+ try:
130
+ self.db = firestore.Client()
131
+ except Exception as e:
132
+ logger.warning(f"Could not initialize Firestore client for prompts: {e}")
133
+
134
+ def get_system_prompt(self, version: str = "latest", ab_test_group: str = None) -> str:
135
+ """Fetch the system prompt from Firestore with optional A/B testing variations."""
136
+ if not self.db:
137
+ return DEFAULT_SYSTEM_PROMPT
138
+
139
+ try:
140
+ # For A/B testing, fetch from the "prompts" collection
141
+ # Using document ID "system_prompt_A" or "system_prompt_B" based on group
142
+ doc_id = f"system_prompt_{ab_test_group}" if ab_test_group else "system_prompt"
143
+ doc_ref = self.db.collection("prompts").document(doc_id)
144
+ doc = doc_ref.get()
145
+
146
+ if doc.exists:
147
+ data = doc.to_dict()
148
+ # Check if specific version requested, otherwise use 'latest' or 'active' pointer
149
+ target_version = version
150
+ if target_version == "latest":
151
+ target_version = data.get("active_version", "v1")
152
+
153
+ template = data.get("versions", {}).get(target_version)
154
+ if template:
155
+ return template
156
+ else:
157
+ logger.warning(f"Version {target_version} not found in Firestore. Using fallback.")
158
+ else:
159
+ logger.warning(f"Prompt document {doc_id} not found in Firestore. Using fallback.")
160
+
161
+ except Exception as e:
162
+ logger.error(f"Error fetching prompt from Firestore: {e}. Using fallback.")
163
+
164
+ return DEFAULT_SYSTEM_PROMPT
src/services/qdrant_service.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Qdrant Vector Service — Hybrid Search (Dense + Sparse).
3
+ """
4
+
5
+ import logging
6
+ from typing import List, Dict, Any, Optional
7
+
8
+ from qdrant_client import QdrantClient
9
+ from langchain_qdrant import QdrantVectorStore, RetrievalMode, FastEmbedSparse
10
+
11
+ from backend.src.config import settings
12
+ from backend.src.services.embedding_service import get_dense_embeddings
13
+
14
+ logger = logging.getLogger("math_assistant.qdrant")
15
+
16
+ class QdrantService:
17
+ """Manages the Qdrant Cloud or local vector store with Hybrid Search."""
18
+
19
+ def __init__(self):
20
+ self.collection_name = settings.collection_name
21
+ self.url = getattr(settings, "qdrant_url", ":memory:")
22
+ self.api_key = getattr(settings, "qdrant_api_key", "")
23
+
24
+ logger.info(f"Initializing Qdrant at {self.url}")
25
+
26
+ # 1. Initialize Client
27
+ if self.url == ":memory:":
28
+ self.client = QdrantClient(location=":memory:")
29
+ else:
30
+ self.client = QdrantClient(
31
+ url=self.url,
32
+ api_key=self.api_key if self.api_key else None
33
+ )
34
+
35
+ # 2. Get dense embeddings
36
+ self.dense_embeddings = get_dense_embeddings()
37
+
38
+ # 3. Setup sparse embeddings for hybrid search
39
+ self.sparse_embeddings = FastEmbedSparse(model_name="Qdrant/bm25")
40
+
41
+ # 4. Create collection if not exists
42
+ if not self.client.collection_exists(self.collection_name):
43
+ from qdrant_client.http import models as rest
44
+
45
+ # Since we are using Hybrid search (dense + sparse), we need to configure vectors properly.
46
+ # But langchain_qdrant handles the schema if we use `from_texts`/`from_documents`.
47
+ # A safer way to initialize an empty hybrid store is to let langchain_qdrant handle it:
48
+ # We can just skip manual creation and let `add_documents` handle it, but `QdrantVectorStore`
49
+ # init requires the collection to exist if we pass `client`.
50
+
51
+ # Let's create it with the correct dimensions for HuggingFace (384) or Gemini (768).
52
+ # But wait, QdrantVectorStore.from_texts() creates everything automatically.
53
+ pass # We will defer instantiation if it doesn't exist, or just use from_documents later.
54
+
55
+ # Actually, LangChain's QdrantVectorStore has a parameter `force_recreate=True/False` or we can just
56
+ # let `from_texts` do the heavy lifting in `add_documents`.
57
+ # For simplicity, we just initialize it:
58
+ try:
59
+ self.vectorstore = QdrantVectorStore(
60
+ client=self.client,
61
+ collection_name=self.collection_name,
62
+ embedding=self.dense_embeddings,
63
+ sparse_embedding=self.sparse_embeddings,
64
+ retrieval_mode=RetrievalMode.HYBRID
65
+ )
66
+ except ValueError as e:
67
+ if "not found" in str(e):
68
+ logger.warning(f"Collection {self.collection_name} not found. It will be created upon first insertion.")
69
+ self.vectorstore = None
70
+ else:
71
+ raise e
72
+
73
+ def add_documents(self, documents: List[Any]):
74
+ """Index documents into Qdrant."""
75
+ logger.info(f"Indexing {len(documents)} documents into Qdrant...")
76
+ if self.vectorstore is None:
77
+ self.vectorstore = QdrantVectorStore.from_documents(
78
+ documents,
79
+ embedding=self.dense_embeddings,
80
+ sparse_embedding=self.sparse_embeddings,
81
+ location=self.url if self.url == ":memory:" else None,
82
+ url=self.url if self.url != ":memory:" else None,
83
+ api_key=self.api_key if self.api_key and self.url != ":memory:" else None,
84
+ collection_name=self.collection_name,
85
+ retrieval_mode=RetrievalMode.HYBRID
86
+ )
87
+ else:
88
+ self.vectorstore.add_documents(documents)
89
+ logger.info("Indexing complete.")
90
+
91
+ def similarity_search(
92
+ self,
93
+ query: str,
94
+ k: int = 5,
95
+ filter_topic: Optional[str] = None,
96
+ filter_class: Optional[str] = None,
97
+ filter_chapter: Optional[str] = None
98
+ ) -> List[Any]:
99
+ """Hybrid search with metadata filtering."""
100
+
101
+ from qdrant_client.http import models as rest
102
+
103
+ filter_conditions = []
104
+ if filter_topic:
105
+ filter_conditions.append(rest.FieldCondition(key="topic", match=rest.MatchValue(value=filter_topic)))
106
+ if filter_class:
107
+ filter_conditions.append(rest.FieldCondition(key="class_level", match=rest.MatchValue(value=filter_class)))
108
+ if filter_chapter:
109
+ filter_conditions.append(rest.FieldCondition(key="chapter", match=rest.MatchValue(value=filter_chapter)))
110
+
111
+ qdrant_filter = rest.Filter(must=filter_conditions) if filter_conditions else None
112
+
113
+ try:
114
+ return self.vectorstore.similarity_search(
115
+ query=query,
116
+ k=k,
117
+ filter=qdrant_filter
118
+ )
119
+ except Exception as e:
120
+ logger.error(f"Qdrant search failed: {e}")
121
+ return []
122
+
123
+ def as_retriever(self, k: int = 5, metadata_filters: Dict[str, Any] = None):
124
+ """Return a LangChain retriever interface with dynamic filters."""
125
+ from qdrant_client.http import models as rest
126
+
127
+ qdrant_filter = None
128
+ if metadata_filters:
129
+ conditions = []
130
+ for key, val in metadata_filters.items():
131
+ conditions.append(rest.FieldCondition(key=key, match=rest.MatchValue(value=val)))
132
+ qdrant_filter = rest.Filter(must=conditions)
133
+
134
+ search_kwargs = {"k": k}
135
+ if qdrant_filter:
136
+ search_kwargs["filter"] = qdrant_filter
137
+
138
+ return self.vectorstore.as_retriever(search_kwargs=search_kwargs)
139
+
140
+ def get_document_count(self) -> int:
141
+ """Count total documents in collection."""
142
+ try:
143
+ return self.client.count(self.collection_name).count
144
+ except Exception:
145
+ return 0
src/services/vector_service.py CHANGED
@@ -137,21 +137,28 @@ class MathVectorStore:
137
 
138
  _PIPELINE_CACHE = {}
139
 
140
-
141
- def build_pipeline(pdf_paths=None, urls=None, text_paths=None, force_rebuild=False) -> MathVectorStore:
142
- """Build or load the full knowledge base pipeline (cached per process)."""
143
- from backend.src.math.knowledge_indexer import MathDataLoader, MathDataPreprocessor, MathTextSplitter
144
-
145
  if "store" in _PIPELINE_CACHE and not force_rebuild:
146
  return _PIPELINE_CACHE["store"]
147
- store = MathVectorStore()
148
- if store.is_ready() and not force_rebuild:
149
- logger.info(f"Knowledge base already built ({store.get_document_count()} docs).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  _PIPELINE_CACHE["store"] = store
151
  return store
152
- raw_docs = MathDataLoader().load_all(pdf_paths=pdf_paths or [], urls=urls or [], text_paths=text_paths or [])
153
- clean_docs = MathDataPreprocessor().preprocess_documents(raw_docs)
154
- chunks = MathTextSplitter().split_documents(clean_docs)
155
- store.build_knowledge_base(chunks)
156
- _PIPELINE_CACHE["store"] = store
157
- return store
 
137
 
138
  _PIPELINE_CACHE = {}
139
 
140
+ def build_pipeline(force_rebuild=False):
141
+ """Build or load the knowledge base pipeline."""
142
+ from backend.src.config import settings
143
+
 
144
  if "store" in _PIPELINE_CACHE and not force_rebuild:
145
  return _PIPELINE_CACHE["store"]
146
+
147
+ if getattr(settings, "vector_db", "qdrant") == "qdrant":
148
+ from backend.src.services.qdrant_service import QdrantService
149
+ store = QdrantService()
150
+ if store.get_document_count() == 0 or force_rebuild:
151
+ from backend.src.math.knowledge_indexer import KnowledgeIndexer
152
+ indexer = KnowledgeIndexer()
153
+ indexer.index_all()
154
+ _PIPELINE_CACHE["store"] = store
155
+ return store
156
+ else:
157
+ # Fallback to Chroma (legacy)
158
+ store = MathVectorStore()
159
+ if store.is_ready() and not force_rebuild:
160
+ logger.info(f"Knowledge base already built ({store.get_document_count()} docs).")
161
+ else:
162
+ logger.warning("Chroma build from scratch is deprecated. Please use Qdrant.")
163
  _PIPELINE_CACHE["store"] = store
164
  return store