feat: complete Phase 11 & 12, finalize Hackathon submission with ADK, LangGraph, and React
Browse files- Dockerfile +21 -15
- requirements.txt +13 -5
- src/agents/__init__.py +0 -0
- src/agents/formatter.py +58 -0
- src/agents/mcp_registry.py +65 -0
- src/agents/memory.py +64 -0
- src/agents/orchestrator.py +74 -0
- src/agents/planner.py +67 -0
- src/agents/solver.py +101 -0
- src/agents/verifier.py +65 -0
- src/api/limiter.py +6 -0
- src/api/middleware/auth.py +60 -0
- src/api/v1/chat.py +46 -14
- src/api/v1/progress.py +116 -0
- src/config.py +44 -4
- src/graph/__init__.py +0 -0
- src/graph/math_graph.py +74 -0
- src/graph/nodes/__init__.py +0 -0
- src/graph/nodes/nodes.py +87 -0
- src/graph/state.py +33 -0
- src/main.py +13 -2
- src/math/knowledge_indexer.py +68 -278
- src/services/embedding_service.py +32 -0
- src/services/evaluation_service.py +101 -0
- src/services/firebase_service.py +81 -0
- src/services/gemini_service.py +93 -81
- src/services/memory_service.py +56 -39
- src/services/prompt_service.py +164 -0
- src/services/qdrant_service.py +145 -0
- src/services/vector_service.py +21 -14
Dockerfile
CHANGED
|
@@ -1,24 +1,30 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
|
| 7 |
-
build-essential \
|
| 8 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# Copy application code
|
| 15 |
-
COPY src
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
USER appuser
|
| 21 |
|
| 22 |
-
EXPOSE
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
|
|
|
|
| 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 (
|
| 28 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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=
|
|
|
|
| 93 |
|
| 94 |
# 1. Get context and history
|
| 95 |
-
source_docs, context = engine._retrieve_context(
|
| 96 |
chat_history = engine.memory.get_langchain_messages(limit=4)
|
| 97 |
-
engine.memory.add_message("human",
|
| 98 |
|
| 99 |
gemini = GeminiService()
|
| 100 |
|
| 101 |
async def generate():
|
| 102 |
full_response = ""
|
| 103 |
try:
|
| 104 |
-
async for chunk in gemini.stream(
|
| 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 |
-
|
|
|
|
| 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 |
-
# ──
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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(
|
|
|
|
| 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 —
|
| 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
|
| 11 |
-
import
|
| 12 |
import logging
|
| 13 |
-
from typing import
|
| 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 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
documents = []
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
except Exception as e:
|
| 131 |
-
logger.
|
| 132 |
-
|
| 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
|
| 285 |
-
"""
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 20 |
|
| 21 |
logger = logging.getLogger("math_assistant.gemini")
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 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.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 129 |
-
|
| 130 |
-
system_text =
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
try:
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 =
|
| 185 |
-
|
| 186 |
-
|
| 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 |
-
|
| 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 =
|
| 216 |
-
|
| 217 |
-
|
| 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 —
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 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
|
| 23 |
-
"""Per-session chat memory with
|
| 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.
|
| 30 |
-
|
| 31 |
-
def
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 52 |
try:
|
| 53 |
-
|
| 54 |
return
|
| 55 |
-
except Exception:
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 62 |
try:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
.
|
| 66 |
.limit(limit)
|
|
|
|
| 67 |
)
|
|
|
|
| 68 |
msgs.reverse()
|
| 69 |
return msgs
|
| 70 |
-
except Exception:
|
| 71 |
-
|
|
|
|
|
|
|
| 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
|
| 80 |
-
result.append(HumanMessage(content=msg
|
| 81 |
-
elif msg
|
| 82 |
-
result.append(AIMessage(content=msg
|
| 83 |
return result
|
| 84 |
|
| 85 |
def clear_history(self):
|
| 86 |
"""Clear all messages for this session."""
|
| 87 |
-
|
|
|
|
| 88 |
try:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
if "store" in _PIPELINE_CACHE and not force_rebuild:
|
| 146 |
return _PIPELINE_CACHE["store"]
|
| 147 |
-
|
| 148 |
-
if
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|