Commit ·
c75184e
0
Parent(s):
v2
Browse files- Dockerfile +24 -0
- __init__.py +1 -0
- requirements.txt +54 -0
- src/__init__.py +1 -0
- src/api/__init__.py +1 -0
- src/api/v1/__init__.py +1 -0
- src/api/v1/chat.py +178 -0
- src/config.py +196 -0
- src/main.py +108 -0
- src/math/__init__.py +1 -0
- src/math/graph_utils.py +115 -0
- src/math/knowledge_indexer.py +290 -0
- src/math/symbolic_engine.py +86 -0
- src/services/__init__.py +1 -0
- src/services/gemini_service.py +233 -0
- src/services/llm_service.py +210 -0
- src/services/memory_service.py +92 -0
- src/services/vector_service.py +157 -0
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies for sentence-transformers and OCR
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Install Python dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy application code
|
| 15 |
+
COPY src/ ./src/
|
| 16 |
+
COPY ../knowledge_base.py ./knowledge_base.py
|
| 17 |
+
|
| 18 |
+
# Non-root user for security
|
| 19 |
+
RUN useradd --create-home appuser && chown -R appuser /app
|
| 20 |
+
USER appuser
|
| 21 |
+
|
| 22 |
+
EXPOSE 8080
|
| 23 |
+
|
| 24 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Backend package
|
requirements.txt
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core LangChain
|
| 2 |
+
langchain>=0.2.0,<0.3.0
|
| 3 |
+
langchain-community>=0.2.0,<0.3.0
|
| 4 |
+
langchain-core>=0.2.0,<0.3.0
|
| 5 |
+
langchain-text-splitters>=0.2.0,<0.3.0
|
| 6 |
+
langchain-groq>=0.1.0
|
| 7 |
+
langchain-huggingface>=0.0.3
|
| 8 |
+
langchain-google-genai>=1.0.6
|
| 9 |
+
google-generativeai>=0.5.4
|
| 10 |
+
|
| 11 |
+
# Vector DB
|
| 12 |
+
chromadb>=0.4.0
|
| 13 |
+
faiss-cpu>=1.7.4
|
| 14 |
+
|
| 15 |
+
# Embeddings — pinned for meta-tensor safety on Python 3.11 / CPU
|
| 16 |
+
sentence-transformers==2.7.0
|
| 17 |
+
transformers==4.40.2
|
| 18 |
+
torch==2.2.2
|
| 19 |
+
tokenizers>=0.19.0,<0.20.0
|
| 20 |
+
huggingface-hub>=0.23.0,<0.24.0
|
| 21 |
+
accelerate>=0.29.0
|
| 22 |
+
|
| 23 |
+
# Data processing
|
| 24 |
+
unstructured>=0.10.0
|
| 25 |
+
pypdf>=3.0.0
|
| 26 |
+
|
| 27 |
+
# Database (memory)
|
| 28 |
+
pymongo>=4.0.0
|
| 29 |
+
motor>=3.0.0
|
| 30 |
+
|
| 31 |
+
# Math
|
| 32 |
+
sympy>=1.12
|
| 33 |
+
numpy>=1.24.0,<2.0.0
|
| 34 |
+
matplotlib>=3.7.0
|
| 35 |
+
scipy>=1.11.0
|
| 36 |
+
|
| 37 |
+
# API Framework
|
| 38 |
+
fastapi>=0.111.0
|
| 39 |
+
uvicorn>=0.29.0
|
| 40 |
+
pydantic>=2.0.0
|
| 41 |
+
pydantic-settings>=2.0.0
|
| 42 |
+
|
| 43 |
+
# Utilities
|
| 44 |
+
python-dotenv>=1.0.0
|
| 45 |
+
requests>=2.31.0
|
| 46 |
+
beautifulsoup4>=4.12.0
|
| 47 |
+
pillow>=10.0.0
|
| 48 |
+
pytesseract>=0.3.10
|
| 49 |
+
plotly>=5.0.0
|
| 50 |
+
|
| 51 |
+
# Testing (dev)
|
| 52 |
+
pytest>=7.0.0
|
| 53 |
+
pytest-cov>=4.0.0
|
| 54 |
+
httpx>=0.27.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Backend source package
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
src/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API v1 package
|
src/api/v1/chat.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat API — /api/v1/chat endpoint for math question answering.
|
| 3 |
+
|
| 4 |
+
Provides a non-streaming POST endpoint that processes math queries
|
| 5 |
+
through the MathAIEngine (RAG + symbolic math + LLM with fallback).
|
| 6 |
+
"""
|
| 7 |
+
|
| 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 |
+
|
| 15 |
+
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 |
+
|
| 22 |
+
router = APIRouter(prefix="/api/v1", tags=["chat"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── Request / Response Models ──────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
class ChatRequest(BaseModel):
|
| 28 |
+
"""Request body for the chat endpoint."""
|
| 29 |
+
query: str = Field(..., min_length=1, max_length=2000, description="Math question to solve")
|
| 30 |
+
session_id: str = Field(default="default", description="Session ID for chat history")
|
| 31 |
+
class_level: Optional[str] = Field(default=None, description="Student class level (e.g., '10', 'JEE')")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ChatResponse(BaseModel):
|
| 35 |
+
"""Response body from the chat endpoint."""
|
| 36 |
+
answer: str
|
| 37 |
+
sources: list = []
|
| 38 |
+
symbolic_hint: Optional[str] = None
|
| 39 |
+
session_id: str
|
| 40 |
+
context_docs: int = 0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class HistoryResponse(BaseModel):
|
| 44 |
+
"""Response body for chat history endpoint."""
|
| 45 |
+
session_id: str
|
| 46 |
+
messages: list
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class VisionResponse(BaseModel):
|
| 50 |
+
"""Response body for the vision extraction endpoint."""
|
| 51 |
+
extracted_math: str
|
| 52 |
+
solution: Optional[str] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── Endpoints ──────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
@router.post("/chat", response_model=ChatResponse)
|
| 58 |
+
async def chat(request: ChatRequest):
|
| 59 |
+
"""Process a math question and return a step-by-step solution.
|
| 60 |
+
|
| 61 |
+
The query flows through:
|
| 62 |
+
1. Symbolic math verification (SymPy)
|
| 63 |
+
2. Vector store retrieval (ChromaDB/FAISS)
|
| 64 |
+
3. Chat history injection
|
| 65 |
+
4. LLM call with automatic model fallback
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
store = build_pipeline()
|
| 69 |
+
engine = MathAIEngine(vector_store=store, session_id=request.session_id)
|
| 70 |
+
result = engine.query(request.query)
|
| 71 |
+
return ChatResponse(**result)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"Chat endpoint error: {e}")
|
| 74 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.post("/chat/stream")
|
| 78 |
+
async def chat_stream(request: ChatRequest):
|
| 79 |
+
"""Stream a math solution token-by-token using SSE.
|
| 80 |
+
|
| 81 |
+
Requires USE_GEMINI=true to function correctly.
|
| 82 |
+
"""
|
| 83 |
+
if not settings.use_gemini:
|
| 84 |
+
raise HTTPException(status_code=400, detail="Streaming requires Gemini API to be enabled.")
|
| 85 |
+
|
| 86 |
+
from backend.src.services.gemini_service import GeminiService
|
| 87 |
+
from backend.src.services.memory_service import MongoDBChatMemory
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# We manually orchestrate the context retrieval for streaming
|
| 91 |
+
store = build_pipeline()
|
| 92 |
+
engine = MathAIEngine(vector_store=store, session_id=request.session_id)
|
| 93 |
+
|
| 94 |
+
# 1. Get context and history
|
| 95 |
+
source_docs, context = engine._retrieve_context(request.query)
|
| 96 |
+
chat_history = engine.memory.get_langchain_messages(limit=4)
|
| 97 |
+
engine.memory.add_message("human", request.query)
|
| 98 |
+
|
| 99 |
+
gemini = GeminiService()
|
| 100 |
+
|
| 101 |
+
async def generate():
|
| 102 |
+
full_response = ""
|
| 103 |
+
try:
|
| 104 |
+
async for chunk in gemini.stream(request.query, context=context, chat_history=chat_history):
|
| 105 |
+
full_response += chunk
|
| 106 |
+
# Format as SSE
|
| 107 |
+
yield f"data: {chunk}\n\n"
|
| 108 |
+
|
| 109 |
+
# Save full response to memory after streaming completes
|
| 110 |
+
engine.memory.add_message("assistant", full_response)
|
| 111 |
+
yield "data: [DONE]\n\n"
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Streaming error: {e}")
|
| 114 |
+
yield f"data: ⚠️ Streaming error: {e}\n\n"
|
| 115 |
+
yield "data: [DONE]\n\n"
|
| 116 |
+
|
| 117 |
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Chat stream endpoint error: {e}")
|
| 121 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.post("/vision/extract", response_model=VisionResponse)
|
| 125 |
+
async def extract_math_from_image(file: UploadFile = File(...), solve: bool = Form(False)):
|
| 126 |
+
"""Extract math from an uploaded image using Gemini Vision."""
|
| 127 |
+
if not settings.use_gemini:
|
| 128 |
+
raise HTTPException(status_code=400, detail="Vision extraction requires Gemini API.")
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
image_bytes = await file.read()
|
| 132 |
+
vision_service = GeminiVisionService()
|
| 133 |
+
|
| 134 |
+
if solve:
|
| 135 |
+
result = vision_service.extract_and_solve(image_bytes, mime_type=file.content_type)
|
| 136 |
+
return VisionResponse(
|
| 137 |
+
extracted_math=result["extracted"],
|
| 138 |
+
solution=result["solution"]
|
| 139 |
+
)
|
| 140 |
+
else:
|
| 141 |
+
extracted = vision_service.extract_math_from_image(image_bytes, mime_type=file.content_type)
|
| 142 |
+
return VisionResponse(extracted_math=extracted)
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"Vision endpoint error: {e}")
|
| 146 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 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 = []
|
| 157 |
+
for msg in history:
|
| 158 |
+
messages.append({
|
| 159 |
+
"role": msg.get("role", "unknown"),
|
| 160 |
+
"content": msg.get("content", ""),
|
| 161 |
+
"timestamp": str(msg.get("timestamp", "")),
|
| 162 |
+
})
|
| 163 |
+
return HistoryResponse(session_id=session_id, messages=messages)
|
| 164 |
+
except Exception as e:
|
| 165 |
+
logger.error(f"History endpoint error: {e}")
|
| 166 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 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:
|
| 177 |
+
logger.error(f"Clear history error: {e}")
|
| 178 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/config.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application configuration — Pydantic Settings loaded from environment variables.
|
| 3 |
+
|
| 4 |
+
All configuration values are centralized here. Modules import from this file
|
| 5 |
+
instead of reading os.environ directly. This is the single source of truth
|
| 6 |
+
for all runtime configuration across the backend.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List
|
| 12 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
+
|
| 14 |
+
# Ensure .env is loaded for local development
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent.parent.parent / ".env", override=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Settings(BaseSettings):
|
| 20 |
+
"""Application settings loaded from environment variables and .env file."""
|
| 21 |
+
|
| 22 |
+
model_config = SettingsConfigDict(
|
| 23 |
+
env_file=".env",
|
| 24 |
+
env_file_encoding="utf-8",
|
| 25 |
+
extra="ignore",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# ── LLM Configuration ──────────────────────────────────────────────
|
| 29 |
+
groq_api_key: str = ""
|
| 30 |
+
llm_model: str = "llama-3.3-70b-versatile"
|
| 31 |
+
groq_model_fallbacks: List[str] = [
|
| 32 |
+
"llama-3.3-70b-versatile",
|
| 33 |
+
"llama3-8b-8192",
|
| 34 |
+
"mixtral-8x7b-32768",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
# ── Gemini Configuration ───────────────────────────────────────────
|
| 38 |
+
gemini_api_key: str = ""
|
| 39 |
+
use_gemini: bool = True # Feature flag: True = Gemini primary, False = Groq
|
| 40 |
+
gemini_primary_model: str = "gemini-2.0-flash"
|
| 41 |
+
gemini_fallback_model: str = "gemini-1.5-flash"
|
| 42 |
+
gemini_vision_model: str = "gemini-2.0-flash"
|
| 43 |
+
gemini_temperature: float = 0.1
|
| 44 |
+
gemini_max_tokens: int = 2048
|
| 45 |
+
|
| 46 |
+
# ── Embeddings ─────────────────────────────────────────────────────
|
| 47 |
+
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
| 48 |
+
|
| 49 |
+
# ── Vector Database ────────────────────────────────────────────────
|
| 50 |
+
vector_db_type: str = "chroma"
|
| 51 |
+
chroma_persist_dir: str = "./chroma_db"
|
| 52 |
+
faiss_index_path: str = "./faiss_index"
|
| 53 |
+
top_k_results: int = 5
|
| 54 |
+
chunk_size: int = 1000
|
| 55 |
+
chunk_overlap: int = 200
|
| 56 |
+
collection_name: str = "math_knowledge_base"
|
| 57 |
+
|
| 58 |
+
# ── MongoDB (Chat Memory) ──────────────────────────────────────────
|
| 59 |
+
mongodb_uri: str = ""
|
| 60 |
+
mongodb_db_name: str = "math_assistant"
|
| 61 |
+
mongodb_collection: str = "chat_history"
|
| 62 |
+
|
| 63 |
+
# ── Server ─────────────────────────────────────────────────────────
|
| 64 |
+
host: str = "0.0.0.0"
|
| 65 |
+
port: int = 8080
|
| 66 |
+
environment: str = "development"
|
| 67 |
+
log_level: str = "INFO"
|
| 68 |
+
|
| 69 |
+
|
| 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
|
| 76 |
+
# used by MathAIEngine to instruct the LLM on response format.
|
| 77 |
+
|
| 78 |
+
SYSTEM_TEMPLATE = """You are an Indian mathematics teacher writing on a whiteboard.
|
| 79 |
+
|
| 80 |
+
⚠️ NON-MATH QUESTIONS:
|
| 81 |
+
If NOT about mathematics → reply ONLY: "❌ I only teach math! Ask me any math problem."
|
| 82 |
+
Stop immediately. Nothing else.
|
| 83 |
+
|
| 84 |
+
════════════════════════════════════════
|
| 85 |
+
THE GOLDEN RULE — READ THIS FIRST:
|
| 86 |
+
════════════════════════════════════════
|
| 87 |
+
|
| 88 |
+
NEVER write paragraphs. NEVER write long sentences explaining theory.
|
| 89 |
+
Write SHORT lines. Like a teacher writing on a board.
|
| 90 |
+
Every line = one idea. One calculation. One small result.
|
| 91 |
+
If a student can't follow in 5 seconds → you wrote too much.
|
| 92 |
+
|
| 93 |
+
WRONG (too much theory, paragraph style):
|
| 94 |
+
"The Commutative Property of Addition states that when we add numbers,
|
| 95 |
+
the order does not matter. This means that 3+4 gives the same result
|
| 96 |
+
as 4+3, which we can verify by counting on a number line..."
|
| 97 |
+
|
| 98 |
+
RIGHT (whiteboard style):
|
| 99 |
+
Step 1 — Check: does order matter in addition?
|
| 100 |
+
3 + 4 = 7
|
| 101 |
+
4 + 3 = 7 ← same answer!
|
| 102 |
+
✓ Yes — order doesn't matter. This is called Commutative Property.
|
| 103 |
+
|
| 104 |
+
════════════════════════════════════════
|
| 105 |
+
FORMAT — FOLLOW EXACTLY EVERY TIME:
|
| 106 |
+
════════════════════════════════════════
|
| 107 |
+
|
| 108 |
+
[One short opening — max 1 line. Like reading the problem aloud.]
|
| 109 |
+
"Okay, quadratic equation. Let's use the formula."
|
| 110 |
+
"Right — we need HCF of two numbers."
|
| 111 |
+
"Alright, let's integrate this step by step."
|
| 112 |
+
|
| 113 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 114 |
+
Question: [restate question clearly]
|
| 115 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 116 |
+
|
| 117 |
+
Step 1 — [title: what and why, max 1 line]
|
| 118 |
+
[calculation line 1]
|
| 119 |
+
[calculation line 2]
|
| 120 |
+
[short teacher note if needed — max 1 line]
|
| 121 |
+
|
| 122 |
+
Step 2 — [title]
|
| 123 |
+
[calculation]
|
| 124 |
+
[result]
|
| 125 |
+
|
| 126 |
+
[only as many steps as needed — no fake steps]
|
| 127 |
+
|
| 128 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 129 |
+
✅ Answer: [final answer]
|
| 130 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 131 |
+
|
| 132 |
+
[One closing line max — "Key thing: watch the sign here!" or "Make sense?"]
|
| 133 |
+
|
| 134 |
+
════════════════════════════════════════
|
| 135 |
+
INSIDE EACH STEP — RULES:
|
| 136 |
+
════════════════════════════════════════
|
| 137 |
+
|
| 138 |
+
✅ DO write like this:
|
| 139 |
+
a = 2, b = 5, c = -3
|
| 140 |
+
b² - 4ac = 25 - 4(2)(-3) = 25 + 24 = 49 ← careful: minus×minus = plus!
|
| 141 |
+
√49 = 7 ← clean number, good sign!
|
| 142 |
+
x = (-5 ± 7) / 4
|
| 143 |
+
|
| 144 |
+
✅ DO add ONE short teacher reaction inline:
|
| 145 |
+
"← careful here" "← minus × minus = plus!" "← nice, simplifies!"
|
| 146 |
+
"← most students miss this" "← remember this!"
|
| 147 |
+
|
| 148 |
+
❌ NEVER write:
|
| 149 |
+
- Paragraphs or long sentences
|
| 150 |
+
- Theory blocks explaining what a property IS
|
| 151 |
+
- Repeated explanations of the same idea
|
| 152 |
+
- More than 1 line of teacher commentary per step
|
| 153 |
+
- Sentences like "In this case, we can see that..." or "This demonstrates..."
|
| 154 |
+
- Definitions ("The quadratic formula is used when...")
|
| 155 |
+
- History or background ("This property was discovered...")
|
| 156 |
+
|
| 157 |
+
════════════════════════════════════════
|
| 158 |
+
DETECT LEVEL — CHANGE DEPTH NOT STYLE:
|
| 159 |
+
════════════════════════════════════════
|
| 160 |
+
|
| 161 |
+
Class 1–5:
|
| 162 |
+
→ Ultra simple. Real objects. ("3 apples + 4 apples = 7 apples")
|
| 163 |
+
→ No jargon. Max 3 steps.
|
| 164 |
+
→ Lots of ✓ and encouragement inline.
|
| 165 |
+
|
| 166 |
+
Class 6–8:
|
| 167 |
+
→ Short friendly lines. Explain WHY in 3-4 words inline.
|
| 168 |
+
→ "← because negative × negative = positive"
|
| 169 |
+
|
| 170 |
+
Class 9–10:
|
| 171 |
+
→ Full working, every line shown.
|
| 172 |
+
→ One inline note on common exam mistake.
|
| 173 |
+
→ "← board exams always ask this"
|
| 174 |
+
|
| 175 |
+
Class 11–12:
|
| 176 |
+
→ State theorem/formula name once, then just use it.
|
| 177 |
+
→ Show every substitution clearly.
|
| 178 |
+
|
| 179 |
+
JEE Advanced:
|
| 180 |
+
→ Full clean solution first.
|
| 181 |
+
→ Then add:
|
| 182 |
+
💡 Key Insight: [one line — the clever observation]
|
| 183 |
+
⏱️ Exam Tip: [one line — what to write quickly]
|
| 184 |
+
|
| 185 |
+
════════════════════════════════════════
|
| 186 |
+
SYMBOLS — STRICT:
|
| 187 |
+
════════════════════════════════════════
|
| 188 |
+
|
| 189 |
+
→ USE LaTeX math mode ($...$ for inline, $$...$$ for block) for all mathematical expressions.
|
| 190 |
+
→ Example: $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$
|
| 191 |
+
→ Use proper LaTeX symbols (e.g. \sqrt, \pi, \pm, \int)
|
| 192 |
+
→ Hindi/Hinglish question → answer in same language
|
| 193 |
+
|
| 194 |
+
Context from knowledge base:
|
| 195 |
+
{context}
|
| 196 |
+
"""
|
src/main.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Application — Advanced Mathematics Assistant Backend.
|
| 3 |
+
|
| 4 |
+
This is the main entry point for the backend API server.
|
| 5 |
+
Run with: uvicorn backend.src.main:app --host 0.0.0.0 --port 8080
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
|
| 15 |
+
from backend.src.config import settings
|
| 16 |
+
from backend.src.api.v1.chat import router as chat_router
|
| 17 |
+
|
| 18 |
+
# ── Logging Setup ──────────────────────────────────────────────────────
|
| 19 |
+
logging.basicConfig(
|
| 20 |
+
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
| 21 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger("math_assistant.app")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── Application Lifespan ──────────────────────────────────────────────
|
| 27 |
+
@asynccontextmanager
|
| 28 |
+
async def lifespan(app: FastAPI):
|
| 29 |
+
"""Startup and shutdown events for the FastAPI application."""
|
| 30 |
+
logger.info("🚀 Starting Advanced Mathematics Assistant API...")
|
| 31 |
+
logger.info(f" Environment: {settings.environment}")
|
| 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
|
| 38 |
+
# build_pipeline()
|
| 39 |
+
|
| 40 |
+
yield # Application is running
|
| 41 |
+
|
| 42 |
+
logger.info("🛑 Shutting down Advanced Mathematics Assistant API.")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── FastAPI App ────────────────────────────────────────────────────────
|
| 46 |
+
app = FastAPI(
|
| 47 |
+
title="Advanced Mathematics Assistant API",
|
| 48 |
+
description=(
|
| 49 |
+
"AI-powered math tutoring API for Indian school students (Class 6-12 + JEE). "
|
| 50 |
+
"Uses RAG retrieval, SymPy symbolic computation, and LLM-based step-by-step solutions."
|
| 51 |
+
),
|
| 52 |
+
version="1.0.0",
|
| 53 |
+
lifespan=lifespan,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ── CORS Middleware ────────────────────────────────────────────────────
|
| 57 |
+
app.add_middleware(
|
| 58 |
+
CORSMiddleware,
|
| 59 |
+
allow_origins=[
|
| 60 |
+
"http://localhost:3000", # React dev server
|
| 61 |
+
"http://localhost:5173", # Vite dev server
|
| 62 |
+
"http://localhost:8501", # Streamlit
|
| 63 |
+
],
|
| 64 |
+
allow_credentials=True,
|
| 65 |
+
allow_methods=["*"],
|
| 66 |
+
allow_headers=["*"],
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# ── Routes ─────────────────────────────────────────────────────────────
|
| 70 |
+
app.include_router(chat_router)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.get("/health")
|
| 74 |
+
async def health_check():
|
| 75 |
+
"""Health check endpoint — returns API status and knowledge base info.
|
| 76 |
+
|
| 77 |
+
Returns HTTP 200 with:
|
| 78 |
+
- status: "ok"
|
| 79 |
+
- environment: current environment
|
| 80 |
+
- kb_docs: number of indexed knowledge base documents (0 if not yet built)
|
| 81 |
+
"""
|
| 82 |
+
kb_docs = 0
|
| 83 |
+
try:
|
| 84 |
+
from backend.src.services.vector_service import _PIPELINE_CACHE
|
| 85 |
+
store = _PIPELINE_CACHE.get("store")
|
| 86 |
+
if store:
|
| 87 |
+
kb_docs = store.get_document_count()
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"status": "ok",
|
| 93 |
+
"environment": settings.environment,
|
| 94 |
+
"version": "1.0.0",
|
| 95 |
+
"kb_docs": kb_docs,
|
| 96 |
+
"llm_model": settings.llm_model,
|
| 97 |
+
"vector_db": settings.vector_db_type,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/")
|
| 102 |
+
async def root():
|
| 103 |
+
"""Root endpoint — redirect info."""
|
| 104 |
+
return {
|
| 105 |
+
"message": "Advanced Mathematics Assistant API",
|
| 106 |
+
"docs": "/docs",
|
| 107 |
+
"health": "/health",
|
| 108 |
+
}
|
src/math/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Math modules package
|
src/math/graph_utils.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graph Utilities — Safe expression evaluation and OCR text extraction.
|
| 3 |
+
|
| 4 |
+
Contains:
|
| 5 |
+
- _safe_evaluate_expression: SymPy-based safe math evaluator (Phase 0 security fix)
|
| 6 |
+
- ocr_extract_text: Tesseract OCR for math image extraction
|
| 7 |
+
|
| 8 |
+
Extracted from main.py L815-L904 (Phase 0 security-fixed version).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import os
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("math_assistant.graph")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def safe_evaluate_expression(expr_str: str, x_array):
|
| 20 |
+
"""Safely evaluate a mathematical expression string over a numpy array.
|
| 21 |
+
|
| 22 |
+
Uses SymPy's sympify() to parse the expression (rejecting arbitrary Python)
|
| 23 |
+
and lambdify() to convert it to a numpy-callable function. This is safe
|
| 24 |
+
because sympify() only understands mathematical syntax, not arbitrary code.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
expr_str: Mathematical expression string (e.g., "sin(x)", "x**2 + 1").
|
| 28 |
+
x_array: numpy array of x values to evaluate the expression over.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
numpy array of y values.
|
| 32 |
+
|
| 33 |
+
Raises:
|
| 34 |
+
ValueError: If the expression contains disallowed constructs.
|
| 35 |
+
sympy.SympifyError: If the expression cannot be parsed as math.
|
| 36 |
+
"""
|
| 37 |
+
import sympy as sp
|
| 38 |
+
import numpy as np
|
| 39 |
+
|
| 40 |
+
# Reject expressions containing dangerous patterns before parsing
|
| 41 |
+
_BLOCKED_PATTERNS = [
|
| 42 |
+
"__", "import", "exec", "eval", "compile", "open", "getattr",
|
| 43 |
+
"setattr", "delattr", "globals", "locals", "vars", "dir",
|
| 44 |
+
"breakpoint", "exit", "quit", "input", "print", "os.",
|
| 45 |
+
"sys.", "subprocess", "shutil", "pathlib",
|
| 46 |
+
]
|
| 47 |
+
expr_lower = expr_str.lower().replace(" ", "")
|
| 48 |
+
for pattern in _BLOCKED_PATTERNS:
|
| 49 |
+
if pattern in expr_lower:
|
| 50 |
+
raise ValueError(f"Blocked: expression contains disallowed pattern '{pattern}'")
|
| 51 |
+
|
| 52 |
+
# Replace caret with power operator
|
| 53 |
+
sanitized = re.sub(r'\^', '**', expr_str.strip())
|
| 54 |
+
|
| 55 |
+
# Define allowed SymPy symbols and functions for parsing
|
| 56 |
+
x = sp.Symbol("x")
|
| 57 |
+
_local_dict = {
|
| 58 |
+
"x": x, "e": sp.E, "pi": sp.pi,
|
| 59 |
+
"sin": sp.sin, "cos": sp.cos, "tan": sp.tan,
|
| 60 |
+
"exp": sp.exp, "log": sp.log, "ln": sp.log,
|
| 61 |
+
"sqrt": sp.sqrt, "abs": sp.Abs,
|
| 62 |
+
"arcsin": sp.asin, "arccos": sp.acos, "arctan": sp.atan,
|
| 63 |
+
"asin": sp.asin, "acos": sp.acos, "atan": sp.atan,
|
| 64 |
+
"sinh": sp.sinh, "cosh": sp.cosh, "tanh": sp.tanh,
|
| 65 |
+
"sec": sp.sec, "csc": sp.csc, "cot": sp.cot,
|
| 66 |
+
"ceiling": sp.ceiling, "floor": sp.floor,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# Parse through SymPy — rejects arbitrary Python code
|
| 70 |
+
sym_expr = sp.sympify(sanitized, locals=_local_dict)
|
| 71 |
+
|
| 72 |
+
# Convert to numpy-callable function — safe, no code execution
|
| 73 |
+
f = sp.lambdify(x, sym_expr, modules=["numpy"])
|
| 74 |
+
y = f(x_array)
|
| 75 |
+
|
| 76 |
+
# Ensure result is array (handles constant expressions like "5" or "pi")
|
| 77 |
+
if not hasattr(y, "__len__"):
|
| 78 |
+
y = np.full_like(x_array, float(y))
|
| 79 |
+
|
| 80 |
+
return y
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def ocr_extract_text(image) -> str:
|
| 84 |
+
"""Extract text from an image using Tesseract OCR.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
image: PIL Image object.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Extracted text string, or error message if OCR fails.
|
| 91 |
+
"""
|
| 92 |
+
try:
|
| 93 |
+
import pytesseract
|
| 94 |
+
from PIL import ImageEnhance as enhance
|
| 95 |
+
|
| 96 |
+
# Set tesseract binary path for common install locations
|
| 97 |
+
for _tess_path in [
|
| 98 |
+
"/opt/homebrew/bin/tesseract", # macOS Apple Silicon (brew)
|
| 99 |
+
"/usr/local/bin/tesseract", # macOS Intel (brew)
|
| 100 |
+
"/usr/bin/tesseract", # Linux
|
| 101 |
+
]:
|
| 102 |
+
if os.path.exists(_tess_path):
|
| 103 |
+
pytesseract.pytesseract.tesseract_cmd = _tess_path
|
| 104 |
+
break
|
| 105 |
+
gray = image.convert("L")
|
| 106 |
+
contrast = enhance.Contrast(gray).enhance(2.0)
|
| 107 |
+
sharpened = enhance.Sharpness(contrast).enhance(2.0)
|
| 108 |
+
text = pytesseract.image_to_string(sharpened, config='--psm 6').strip()
|
| 109 |
+
text = ' '.join(text.replace('\n', ' ').split())
|
| 110 |
+
return text
|
| 111 |
+
except ImportError:
|
| 112 |
+
return "ERROR_NO_PYTESSERACT"
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"OCR error: {e}")
|
| 115 |
+
return ""
|
src/math/knowledge_indexer.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Knowledge Base Indexing Pipeline — Data loading, preprocessing, and chunking.
|
| 3 |
+
|
| 4 |
+
Extracted from main.py L80-L270. Contains:
|
| 5 |
+
- MathDataLoader: loads NCERT knowledge base from knowledge_base.py
|
| 6 |
+
- MathDataPreprocessor: cleans, deduplicates, detects topics
|
| 7 |
+
- MathTextSplitter: splits documents into chunks for vector indexing
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import hashlib
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Dict, List
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("math_assistant.indexer")
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from langchain_core.documents import Document
|
| 20 |
+
except ImportError:
|
| 21 |
+
try:
|
| 22 |
+
from langchain.schema import Document
|
| 23 |
+
except ImportError:
|
| 24 |
+
class Document:
|
| 25 |
+
def __init__(self, page_content: str, metadata: dict = None):
|
| 26 |
+
self.page_content = page_content
|
| 27 |
+
self.metadata = metadata or {}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class MathDataLoader:
|
| 31 |
+
"""Load NCERT math knowledge base from hardcoded knowledge_base.py."""
|
| 32 |
+
|
| 33 |
+
def load_builtin_knowledge(self) -> List[Document]:
|
| 34 |
+
"""Load the hand-curated NCERT knowledge base.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
List of Document objects with topic metadata.
|
| 38 |
+
"""
|
| 39 |
+
# Import from the project root knowledge_base.py
|
| 40 |
+
import sys
|
| 41 |
+
project_root = str(Path(__file__).resolve().parent.parent.parent.parent)
|
| 42 |
+
if project_root not in sys.path:
|
| 43 |
+
sys.path.insert(0, project_root)
|
| 44 |
+
|
| 45 |
+
from knowledge_base import MATH_KNOWLEDGE_BASE, CLASS_EXAMPLES
|
| 46 |
+
documents = []
|
| 47 |
+
|
| 48 |
+
# Process topic documents
|
| 49 |
+
for topic, sections in MATH_KNOWLEDGE_BASE.items():
|
| 50 |
+
if isinstance(sections, dict):
|
| 51 |
+
for sub_topic, content in sections.items():
|
| 52 |
+
if isinstance(content, str) and len(content.strip()) > 50:
|
| 53 |
+
documents.append(Document(
|
| 54 |
+
page_content=content,
|
| 55 |
+
metadata={"source": "builtin_kb", "topic": topic,
|
| 56 |
+
"sub_topic": sub_topic}
|
| 57 |
+
))
|
| 58 |
+
elif isinstance(content, dict):
|
| 59 |
+
for k, v in content.items():
|
| 60 |
+
if isinstance(v, str) and len(v.strip()) > 50:
|
| 61 |
+
documents.append(Document(
|
| 62 |
+
page_content=v,
|
| 63 |
+
metadata={"source": "builtin_kb", "topic": topic,
|
| 64 |
+
"sub_topic": f"{sub_topic}/{k}"}
|
| 65 |
+
))
|
| 66 |
+
elif isinstance(sections, str) and len(sections.strip()) > 50:
|
| 67 |
+
documents.append(Document(
|
| 68 |
+
page_content=sections,
|
| 69 |
+
metadata={"source": "builtin_kb", "topic": topic}
|
| 70 |
+
))
|
| 71 |
+
|
| 72 |
+
# Process class examples
|
| 73 |
+
for class_id, chapters in CLASS_EXAMPLES.items():
|
| 74 |
+
if isinstance(chapters, dict):
|
| 75 |
+
for chapter, questions in chapters.items():
|
| 76 |
+
if isinstance(questions, list):
|
| 77 |
+
for q in questions:
|
| 78 |
+
if isinstance(q, dict):
|
| 79 |
+
text = q.get("question", "") + "\n" + q.get("solution", "")
|
| 80 |
+
if len(text.strip()) > 50:
|
| 81 |
+
documents.append(Document(
|
| 82 |
+
page_content=text,
|
| 83 |
+
metadata={"source": "ncert_examples",
|
| 84 |
+
"class": class_id,
|
| 85 |
+
"chapter": chapter,
|
| 86 |
+
"topic": q.get("topic", "general_math")}
|
| 87 |
+
))
|
| 88 |
+
elif isinstance(questions, str) and len(questions.strip()) > 50:
|
| 89 |
+
documents.append(Document(
|
| 90 |
+
page_content=questions,
|
| 91 |
+
metadata={"source": "ncert_examples",
|
| 92 |
+
"class": class_id, "chapter": chapter}
|
| 93 |
+
))
|
| 94 |
+
|
| 95 |
+
logger.info(f"Loaded {len(documents)} documents from built-in knowledge base")
|
| 96 |
+
return documents
|
| 97 |
+
|
| 98 |
+
def load_pdf(self, pdf_path: str) -> List[Document]:
|
| 99 |
+
"""Load a single PDF file."""
|
| 100 |
+
try:
|
| 101 |
+
from langchain_community.document_loaders import PyPDFLoader
|
| 102 |
+
docs = PyPDFLoader(pdf_path).load()
|
| 103 |
+
logger.info(f"Loaded {len(docs)} pages from: {pdf_path}")
|
| 104 |
+
return docs
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Failed to load PDF {pdf_path}: {e}")
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
def load_pdfs_from_directory(self, dir_path: str) -> List[Document]:
|
| 110 |
+
"""Load all PDFs from a directory."""
|
| 111 |
+
try:
|
| 112 |
+
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
|
| 113 |
+
docs = DirectoryLoader(dir_path, glob="**/*.pdf", loader_cls=PyPDFLoader).load()
|
| 114 |
+
logger.info(f"Loaded {len(docs)} documents from: {dir_path}")
|
| 115 |
+
return docs
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"Failed to load PDFs from {dir_path}: {e}")
|
| 118 |
+
return []
|
| 119 |
+
|
| 120 |
+
def load_web_pages(self, urls: List[str]) -> List[Document]:
|
| 121 |
+
"""Load content from web URLs."""
|
| 122 |
+
from langchain_community.document_loaders import WebBaseLoader
|
| 123 |
+
docs = []
|
| 124 |
+
for url in urls:
|
| 125 |
+
try:
|
| 126 |
+
loader = WebBaseLoader(url)
|
| 127 |
+
loader.requests_kwargs = {"timeout": 15}
|
| 128 |
+
docs.extend(loader.load())
|
| 129 |
+
logger.info(f"Loaded: {url}")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.warning(f"Failed URL {url}: {e}")
|
| 132 |
+
return docs
|
| 133 |
+
|
| 134 |
+
def load_text_file(self, file_path: str) -> List[Document]:
|
| 135 |
+
"""Load a text or markdown file."""
|
| 136 |
+
try:
|
| 137 |
+
if file_path.endswith(".md"):
|
| 138 |
+
from langchain_community.document_loaders import UnstructuredMarkdownLoader
|
| 139 |
+
loader = UnstructuredMarkdownLoader(file_path)
|
| 140 |
+
else:
|
| 141 |
+
from langchain_community.document_loaders import TextLoader
|
| 142 |
+
loader = TextLoader(file_path, encoding="utf-8")
|
| 143 |
+
docs = loader.load()
|
| 144 |
+
logger.info(f"Loaded: {file_path}")
|
| 145 |
+
return docs
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Failed to load {file_path}: {e}")
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
def load_all(self, pdf_paths=None, urls=None, text_paths=None, pdf_directory=None) -> List[Document]:
|
| 151 |
+
"""Load all data sources: builtin KB + PDFs + URLs + text files."""
|
| 152 |
+
import os
|
| 153 |
+
all_docs = self.load_builtin_knowledge()
|
| 154 |
+
if pdf_paths:
|
| 155 |
+
for p in pdf_paths:
|
| 156 |
+
all_docs.extend(self.load_pdf(p))
|
| 157 |
+
if pdf_directory and os.path.exists(pdf_directory):
|
| 158 |
+
all_docs.extend(self.load_pdfs_from_directory(pdf_directory))
|
| 159 |
+
if urls:
|
| 160 |
+
all_docs.extend(self.load_web_pages(urls))
|
| 161 |
+
if text_paths:
|
| 162 |
+
for p in text_paths:
|
| 163 |
+
all_docs.extend(self.load_text_file(p))
|
| 164 |
+
logger.info(f"Total documents loaded: {len(all_docs)}")
|
| 165 |
+
return all_docs
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class MathDataPreprocessor:
|
| 169 |
+
"""Clean, deduplicate, and enrich math documents with metadata."""
|
| 170 |
+
|
| 171 |
+
TOPIC_KEYWORDS: Dict[str, List[str]] = {
|
| 172 |
+
"calculus": ["derivative", "integral", "differentiate", "integrate", "limit", "continuity", "taylor"],
|
| 173 |
+
"linear_algebra": ["matrix", "vector", "eigenvalue", "determinant", "rank", "span", "basis"],
|
| 174 |
+
"statistics": ["probability", "distribution", "mean", "variance", "regression", "hypothesis"],
|
| 175 |
+
"algebra": ["polynomial", "equation", "quadratic", "factor", "root", "logarithm", "exponent"],
|
| 176 |
+
"trigonometry": ["sine", "cosine", "tangent", "angle", "radian", "unit circle", "trig"],
|
| 177 |
+
"discrete_math": ["graph", "combinatorics", "permutation", "combination", "modular", "prime"],
|
| 178 |
+
"geometry": ["triangle", "circle", "area", "volume", "perimeter", "pythagorean", "coordinate"],
|
| 179 |
+
"number_theory": ["prime", "divisor", "gcd", "lcm", "modular", "congruence", "integer"],
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
def __init__(self):
|
| 183 |
+
self._seen_hashes: set = set()
|
| 184 |
+
|
| 185 |
+
def _clean(self, text: str) -> str:
|
| 186 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 187 |
+
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 188 |
+
text = re.sub(r"Page\s+\d+\s+of\s+\d+", "", text, flags=re.IGNORECASE)
|
| 189 |
+
text = re.sub(r"https?://\S+", "[URL]", text)
|
| 190 |
+
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
|
| 191 |
+
replacements = {
|
| 192 |
+
"\u2019": "'", "\u201c": '"', "\u201d": '"',
|
| 193 |
+
"\u2013": "-", "\u2014": "--", "\u00a0": " ",
|
| 194 |
+
"\u03c0": "pi", "\u221e": "infinity",
|
| 195 |
+
"\u2264": "<=", "\u2265": ">="
|
| 196 |
+
}
|
| 197 |
+
for old, new in replacements.items():
|
| 198 |
+
text = text.replace(old, new)
|
| 199 |
+
return text.strip()
|
| 200 |
+
|
| 201 |
+
def _detect_topic(self, text: str) -> str:
|
| 202 |
+
tl = text.lower()
|
| 203 |
+
scores = {t: sum(1 for kw in kws if kw in tl) for t, kws in self.TOPIC_KEYWORDS.items()}
|
| 204 |
+
scores = {k: v for k, v in scores.items() if v > 0}
|
| 205 |
+
return max(scores, key=scores.get) if scores else "general_math"
|
| 206 |
+
|
| 207 |
+
def _difficulty(self, text: str) -> str:
|
| 208 |
+
adv = ["eigenvalue", "differential equation", "fourier", "laplace", "manifold", "tensor"]
|
| 209 |
+
mid = ["derivative", "integral", "matrix", "probability", "polynomial", "logarithm"]
|
| 210 |
+
tl = text.lower()
|
| 211 |
+
if sum(1 for t in adv if t in tl) >= 2: return "advanced"
|
| 212 |
+
if sum(1 for t in mid if t in tl) >= 2: return "intermediate"
|
| 213 |
+
return "beginner"
|
| 214 |
+
|
| 215 |
+
def preprocess_document(self, doc):
|
| 216 |
+
text = doc.page_content
|
| 217 |
+
if len(text.strip()) < 50:
|
| 218 |
+
return None
|
| 219 |
+
text = self._clean(text)
|
| 220 |
+
h = hashlib.md5(text.strip().lower().encode()).hexdigest()
|
| 221 |
+
if h in self._seen_hashes:
|
| 222 |
+
return None
|
| 223 |
+
self._seen_hashes.add(h)
|
| 224 |
+
meta = doc.metadata.copy()
|
| 225 |
+
meta.update({
|
| 226 |
+
"topic": meta.get("topic") or self._detect_topic(text),
|
| 227 |
+
"difficulty": self._difficulty(text),
|
| 228 |
+
"char_count": len(text),
|
| 229 |
+
"word_count": len(text.split()),
|
| 230 |
+
"content_hash": h,
|
| 231 |
+
})
|
| 232 |
+
return Document(page_content=text, metadata=meta)
|
| 233 |
+
|
| 234 |
+
def preprocess_documents(self, documents):
|
| 235 |
+
logger.info(f"Preprocessing {len(documents)} documents...")
|
| 236 |
+
self._seen_hashes.clear()
|
| 237 |
+
processed = [r for doc in documents if (r := self.preprocess_document(doc)) is not None]
|
| 238 |
+
logger.info(f"Done: {len(processed)} kept, {len(documents)-len(processed)} skipped")
|
| 239 |
+
return processed
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class MathTextSplitter:
|
| 243 |
+
"""Split documents into overlapping chunks optimized for math content."""
|
| 244 |
+
|
| 245 |
+
def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):
|
| 246 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
|
| 247 |
+
self.chunk_size = chunk_size
|
| 248 |
+
self.chunk_overlap = chunk_overlap
|
| 249 |
+
self._markdown_splitter = MarkdownHeaderTextSplitter(
|
| 250 |
+
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
|
| 251 |
+
strip_headers=False,
|
| 252 |
+
)
|
| 253 |
+
self._text_splitter = RecursiveCharacterTextSplitter(
|
| 254 |
+
chunk_size=chunk_size,
|
| 255 |
+
chunk_overlap=chunk_overlap,
|
| 256 |
+
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
def split_document(self, doc) -> list:
|
| 260 |
+
"""Split a single document into chunks with metadata preserved."""
|
| 261 |
+
try:
|
| 262 |
+
md_chunks = self._markdown_splitter.split_text(doc.page_content)
|
| 263 |
+
if len(md_chunks) > 1:
|
| 264 |
+
chunks = []
|
| 265 |
+
for mc in md_chunks:
|
| 266 |
+
sub_chunks = self._text_splitter.split_text(mc.page_content)
|
| 267 |
+
for sc in sub_chunks:
|
| 268 |
+
meta = {**doc.metadata, **mc.metadata}
|
| 269 |
+
chunks.append(Document(page_content=sc, metadata=meta))
|
| 270 |
+
final = chunks
|
| 271 |
+
else:
|
| 272 |
+
texts = self._text_splitter.split_text(doc.page_content)
|
| 273 |
+
final = [Document(page_content=t, metadata=doc.metadata.copy()) for t in texts]
|
| 274 |
+
except Exception:
|
| 275 |
+
texts = self._text_splitter.split_text(doc.page_content)
|
| 276 |
+
final = [Document(page_content=t, metadata=doc.metadata.copy()) for t in texts]
|
| 277 |
+
|
| 278 |
+
for i, chunk in enumerate(final):
|
| 279 |
+
chunk.metadata["chunk_index"] = i
|
| 280 |
+
chunk.metadata["total_chunks"] = len(final)
|
| 281 |
+
|
| 282 |
+
return final
|
| 283 |
+
|
| 284 |
+
def split_documents(self, documents) -> list:
|
| 285 |
+
"""Split multiple documents and flatten into a single list."""
|
| 286 |
+
all_chunks = []
|
| 287 |
+
for doc in documents:
|
| 288 |
+
all_chunks.extend(self.split_document(doc))
|
| 289 |
+
logger.info(f"Split {len(documents)} documents into {len(all_chunks)} chunks")
|
| 290 |
+
return all_chunks
|
src/math/symbolic_engine.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Symbolic Mathematics Engine — SymPy-based exact computation.
|
| 3 |
+
|
| 4 |
+
Extracted from main.py L585-L643. Provides differentiation, integration,
|
| 5 |
+
equation solving, simplification, and matrix operations using SymPy.
|
| 6 |
+
All methods are stateless (static) and safe for concurrent use.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
import ast
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Dict, Any, Optional
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("math_assistant.symbolic")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SymbolicMathEngine:
|
| 18 |
+
"""SymPy-powered symbolic math solver for exact computation."""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def differentiate(expression: str, variable: str = "x") -> Optional[str]:
|
| 22 |
+
"""Compute the derivative of an expression with respect to a variable."""
|
| 23 |
+
try:
|
| 24 |
+
import sympy as sp
|
| 25 |
+
var = sp.Symbol(variable)
|
| 26 |
+
expr = sp.sympify(re.sub(r'\^', '**', expression.strip()))
|
| 27 |
+
return f"d/d{variable}[{expression}] = {sp.simplify(sp.diff(expr, var))}"
|
| 28 |
+
except Exception:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def integrate(expression: str, variable: str = "x") -> Optional[str]:
|
| 33 |
+
"""Compute the indefinite integral of an expression."""
|
| 34 |
+
try:
|
| 35 |
+
import sympy as sp
|
| 36 |
+
var = sp.Symbol(variable)
|
| 37 |
+
expr = sp.sympify(re.sub(r'\^', '**', expression.strip()))
|
| 38 |
+
return f"integral({expression}) d{variable} = {sp.integrate(expr, var)} + C"
|
| 39 |
+
except Exception:
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def solve_equation(equation: str, variable: str = "x") -> Optional[str]:
|
| 44 |
+
"""Solve an equation for a given variable."""
|
| 45 |
+
try:
|
| 46 |
+
import sympy as sp
|
| 47 |
+
var = sp.Symbol(variable)
|
| 48 |
+
eq_str = re.sub(r'\^', '**', equation.strip())
|
| 49 |
+
if "=" in eq_str:
|
| 50 |
+
lhs, rhs = eq_str.split("=", 1)
|
| 51 |
+
eq = sp.Eq(sp.sympify(lhs), sp.sympify(rhs))
|
| 52 |
+
else:
|
| 53 |
+
eq = sp.sympify(eq_str)
|
| 54 |
+
return f"Solutions for {variable}: {sp.solve(eq, var)}"
|
| 55 |
+
except Exception:
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def try_solve(expression: str) -> Optional[str]:
|
| 60 |
+
"""Attempt to simplify or solve a general expression."""
|
| 61 |
+
try:
|
| 62 |
+
import sympy as sp
|
| 63 |
+
x, y, z, t = sp.symbols('x y z t')
|
| 64 |
+
expr_str = re.sub(r'\^', '**', expression.strip())
|
| 65 |
+
result = sp.simplify(sp.sympify(expr_str, locals={
|
| 66 |
+
'x': x, 'y': y, 'z': z, 't': t,
|
| 67 |
+
'sin': sp.sin, 'cos': sp.cos, 'exp': sp.exp,
|
| 68 |
+
'log': sp.log, 'sqrt': sp.sqrt, 'pi': sp.pi}))
|
| 69 |
+
return str(result)
|
| 70 |
+
except Exception:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def matrix_operations(matrix_str: str) -> Optional[Dict[str, Any]]:
|
| 75 |
+
"""Compute determinant, rank, eigenvalues, and trace of a matrix."""
|
| 76 |
+
try:
|
| 77 |
+
import sympy as sp
|
| 78 |
+
M = sp.Matrix(ast.literal_eval(matrix_str))
|
| 79 |
+
return {
|
| 80 |
+
"determinant": str(M.det()),
|
| 81 |
+
"rank": M.rank(),
|
| 82 |
+
"eigenvalues": str(M.eigenvals()),
|
| 83 |
+
"trace": str(M.trace()),
|
| 84 |
+
}
|
| 85 |
+
except Exception:
|
| 86 |
+
return None
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
src/services/gemini_service.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini Service — Google AI Studio integration for math tutoring.
|
| 3 |
+
|
| 4 |
+
Provides:
|
| 5 |
+
- GeminiService: Text-based math query processing with Gemini 2.0 Flash
|
| 6 |
+
- GeminiVisionService: Image math extraction using Gemini Vision
|
| 7 |
+
- Streaming support for token-by-token SSE delivery
|
| 8 |
+
- Automatic fallback from Gemini 2.0 Flash → Gemini 1.5 Flash on rate limits
|
| 9 |
+
|
| 10 |
+
Uses Google AI Studio free tier (google-generativeai SDK).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
import logging
|
| 16 |
+
import base64
|
| 17 |
+
from typing import Dict, Any, Optional, AsyncGenerator
|
| 18 |
+
|
| 19 |
+
from backend.src.config import settings, SYSTEM_TEMPLATE
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("math_assistant.gemini")
|
| 22 |
+
|
| 23 |
+
_GEMINI_CACHE = {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _get_gemini_model(model_name: str = None):
|
| 27 |
+
"""Get or create a cached Gemini GenerativeModel instance.
|
| 28 |
+
|
| 29 |
+
Uses google-generativeai SDK (Google AI Studio free tier).
|
| 30 |
+
"""
|
| 31 |
+
import google.generativeai as genai
|
| 32 |
+
|
| 33 |
+
model_name = model_name or settings.gemini_primary_model
|
| 34 |
+
if model_name not in _GEMINI_CACHE:
|
| 35 |
+
api_key = settings.gemini_api_key
|
| 36 |
+
if not api_key:
|
| 37 |
+
raise ValueError(
|
| 38 |
+
"GEMINI_API_KEY not set. Get a free key at https://aistudio.google.com/apikey "
|
| 39 |
+
"and add it to your .env file."
|
| 40 |
+
)
|
| 41 |
+
genai.configure(api_key=api_key)
|
| 42 |
+
_GEMINI_CACHE[model_name] = genai.GenerativeModel(
|
| 43 |
+
model_name=model_name,
|
| 44 |
+
generation_config=genai.GenerationConfig(
|
| 45 |
+
temperature=settings.gemini_temperature,
|
| 46 |
+
max_output_tokens=settings.gemini_max_tokens,
|
| 47 |
+
),
|
| 48 |
+
)
|
| 49 |
+
logger.info(f"Initialized Gemini model: {model_name}")
|
| 50 |
+
return _GEMINI_CACHE[model_name]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class GeminiService:
|
| 54 |
+
"""Google Gemini text-based math query service with automatic fallback."""
|
| 55 |
+
|
| 56 |
+
def __init__(self):
|
| 57 |
+
self.primary_model = settings.gemini_primary_model
|
| 58 |
+
self.fallback_model = settings.gemini_fallback_model
|
| 59 |
+
|
| 60 |
+
def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
|
| 61 |
+
"""Send a math query to Gemini and return the response.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
user_input: The student's math question.
|
| 65 |
+
context: RAG-retrieved knowledge base context.
|
| 66 |
+
chat_history: List of previous messages for context.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
The LLM response string.
|
| 70 |
+
"""
|
| 71 |
+
system_text = SYSTEM_TEMPLATE.replace("{context}", context or "")
|
| 72 |
+
|
| 73 |
+
# Build conversation parts
|
| 74 |
+
parts = [system_text + "\n\n"]
|
| 75 |
+
if chat_history:
|
| 76 |
+
for msg in chat_history:
|
| 77 |
+
role = getattr(msg, "type", "human")
|
| 78 |
+
content = getattr(msg, "content", str(msg))
|
| 79 |
+
prefix = "Student: " if role == "human" else "Teacher: "
|
| 80 |
+
parts.append(f"{prefix}{content}\n")
|
| 81 |
+
parts.append(f"Student: {user_input}\nTeacher: ")
|
| 82 |
+
|
| 83 |
+
prompt = "".join(parts)
|
| 84 |
+
|
| 85 |
+
# Try primary model, then fallback
|
| 86 |
+
models_to_try = [self.primary_model, self.fallback_model]
|
| 87 |
+
last_error = None
|
| 88 |
+
|
| 89 |
+
for model_name in models_to_try:
|
| 90 |
+
model = _get_gemini_model(model_name)
|
| 91 |
+
for attempt in range(2):
|
| 92 |
+
try:
|
| 93 |
+
response = model.generate_content(prompt)
|
| 94 |
+
|
| 95 |
+
if response.text:
|
| 96 |
+
answer = response.text.strip()
|
| 97 |
+
if model_name != self.primary_model:
|
| 98 |
+
answer = f"*(Using fallback: {model_name})*\n\n" + answer
|
| 99 |
+
return answer
|
| 100 |
+
else:
|
| 101 |
+
# Check for blocked content
|
| 102 |
+
if response.prompt_feedback and response.prompt_feedback.block_reason:
|
| 103 |
+
raise Exception(f"Content blocked: {response.prompt_feedback.block_reason}")
|
| 104 |
+
raise Exception("Empty response from Gemini")
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
last_error = e
|
| 108 |
+
err_str = str(e).lower()
|
| 109 |
+
logger.warning(f"Gemini {model_name} attempt {attempt+1} failed: {e}")
|
| 110 |
+
|
| 111 |
+
if "429" in str(e) or "resource_exhausted" in err_str or "quota" in err_str:
|
| 112 |
+
logger.info(f"Rate limit on {model_name}, trying fallback...")
|
| 113 |
+
time.sleep(1)
|
| 114 |
+
break # Skip to next model
|
| 115 |
+
elif "timeout" in err_str or "deadline" in err_str:
|
| 116 |
+
time.sleep(1)
|
| 117 |
+
continue
|
| 118 |
+
else:
|
| 119 |
+
break # Non-retryable
|
| 120 |
+
|
| 121 |
+
# All models failed
|
| 122 |
+
logger.error(f"All Gemini models failed: {last_error}")
|
| 123 |
+
return f"⚠️ Gemini API error: {last_error}"
|
| 124 |
+
|
| 125 |
+
async def stream(self, user_input: str, context: str = "", chat_history: list = None) -> AsyncGenerator[str, None]:
|
| 126 |
+
"""Stream a response token by token for SSE delivery.
|
| 127 |
+
|
| 128 |
+
Yields chunks of text as they arrive from Gemini's streaming API.
|
| 129 |
+
"""
|
| 130 |
+
system_text = SYSTEM_TEMPLATE.replace("{context}", context or "")
|
| 131 |
+
parts = [system_text + "\n\n"]
|
| 132 |
+
if chat_history:
|
| 133 |
+
for msg in chat_history:
|
| 134 |
+
role = getattr(msg, "type", "human")
|
| 135 |
+
content = getattr(msg, "content", str(msg))
|
| 136 |
+
prefix = "Student: " if role == "human" else "Teacher: "
|
| 137 |
+
parts.append(f"{prefix}{content}\n")
|
| 138 |
+
parts.append(f"Student: {user_input}\nTeacher: ")
|
| 139 |
+
prompt = "".join(parts)
|
| 140 |
+
|
| 141 |
+
model = _get_gemini_model(self.primary_model)
|
| 142 |
+
try:
|
| 143 |
+
response = model.generate_content(prompt, stream=True)
|
| 144 |
+
for chunk in response:
|
| 145 |
+
if chunk.text:
|
| 146 |
+
yield chunk.text
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"Streaming error: {e}")
|
| 149 |
+
yield f"\n\n⚠️ Streaming error: {e}"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class GeminiVisionService:
|
| 153 |
+
"""Gemini Vision for extracting math from images (replaces Tesseract OCR)."""
|
| 154 |
+
|
| 155 |
+
def __init__(self):
|
| 156 |
+
self.model_name = settings.gemini_vision_model
|
| 157 |
+
|
| 158 |
+
def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
|
| 159 |
+
"""Extract mathematical content from an image using Gemini Vision.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
image_bytes: Raw image bytes.
|
| 163 |
+
mime_type: Image MIME type (image/jpeg, image/png, etc.).
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
Extracted mathematical expression or problem text.
|
| 167 |
+
"""
|
| 168 |
+
import google.generativeai as genai
|
| 169 |
+
|
| 170 |
+
model = _get_gemini_model(self.model_name)
|
| 171 |
+
|
| 172 |
+
prompt = (
|
| 173 |
+
"You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
|
| 174 |
+
"Rules:\n"
|
| 175 |
+
"1. Convert handwritten or printed math into clean text.\n"
|
| 176 |
+
"2. Use standard notation: x², √, π, ±, ∫, Σ, etc.\n"
|
| 177 |
+
"3. If it's an equation, write it as: equation\n"
|
| 178 |
+
"4. If it's a word problem, transcribe it exactly.\n"
|
| 179 |
+
"5. If there are multiple problems, number them.\n"
|
| 180 |
+
"6. Return ONLY the extracted math — no commentary.\n"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
response = model.generate_content([
|
| 185 |
+
prompt,
|
| 186 |
+
{"mime_type": mime_type, "data": image_bytes},
|
| 187 |
+
])
|
| 188 |
+
if response.text:
|
| 189 |
+
return response.text.strip()
|
| 190 |
+
return "Could not extract math from image."
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Vision extraction error: {e}")
|
| 193 |
+
return f"Vision error: {e}"
|
| 194 |
+
|
| 195 |
+
def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
|
| 196 |
+
"""Extract math from image and solve it in one call.
|
| 197 |
+
|
| 198 |
+
Returns dict with 'extracted' (the raw math) and 'solution' (step-by-step).
|
| 199 |
+
"""
|
| 200 |
+
import google.generativeai as genai
|
| 201 |
+
|
| 202 |
+
model = _get_gemini_model(self.model_name)
|
| 203 |
+
|
| 204 |
+
prompt = (
|
| 205 |
+
"You are an Indian math teacher. Look at this image.\n\n"
|
| 206 |
+
"Step 1: Extract the math problem from the image.\n"
|
| 207 |
+
"Step 2: Solve it step-by-step in whiteboard style.\n\n"
|
| 208 |
+
"Format your response as:\n"
|
| 209 |
+
"EXTRACTED: [the math problem]\n"
|
| 210 |
+
"---\n"
|
| 211 |
+
"SOLUTION:\n[step-by-step solution]\n"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
response = model.generate_content([
|
| 216 |
+
prompt,
|
| 217 |
+
{"mime_type": mime_type, "data": image_bytes},
|
| 218 |
+
])
|
| 219 |
+
text = response.text.strip() if response.text else ""
|
| 220 |
+
|
| 221 |
+
# Parse response
|
| 222 |
+
if "EXTRACTED:" in text and "SOLUTION:" in text:
|
| 223 |
+
parts = text.split("---", 1)
|
| 224 |
+
extracted = parts[0].replace("EXTRACTED:", "").strip()
|
| 225 |
+
solution = parts[1].replace("SOLUTION:", "").strip() if len(parts) > 1 else ""
|
| 226 |
+
else:
|
| 227 |
+
extracted = ""
|
| 228 |
+
solution = text
|
| 229 |
+
|
| 230 |
+
return {"extracted": extracted, "solution": solution}
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.error(f"Extract and solve error: {e}")
|
| 233 |
+
return {"extracted": "", "solution": f"⚠️ Error: {e}"}
|
src/services/llm_service.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM Service — Gemini (primary) + Groq (fallback) orchestrator.
|
| 3 |
+
|
| 4 |
+
Phase 2: Gemini Integration.
|
| 5 |
+
- When USE_GEMINI=true (default), queries go through GeminiService.
|
| 6 |
+
- When USE_GEMINI=false or GEMINI_API_KEY is missing, falls back to Groq.
|
| 7 |
+
- _get_llm: Groq LLM factory (preserved for fallback)
|
| 8 |
+
- MathAIEngine: Orchestrates retrieval, symbolic math, and LLM calls.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Dict, Any, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from backend.src.config import settings, SYSTEM_TEMPLATE
|
| 17 |
+
from backend.src.math.symbolic_engine import SymbolicMathEngine
|
| 18 |
+
from backend.src.services.memory_service import MongoDBChatMemory
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
| 22 |
+
except ImportError:
|
| 23 |
+
from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("math_assistant.llm")
|
| 26 |
+
|
| 27 |
+
_LLM_CACHE = {}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _get_llm(model=None):
|
| 31 |
+
"""Get or create a cached Groq LLM instance for a given model name."""
|
| 32 |
+
key = model or settings.groq_model_fallbacks[0]
|
| 33 |
+
if key not in _LLM_CACHE:
|
| 34 |
+
from langchain_groq import ChatGroq
|
| 35 |
+
api_key = settings.groq_api_key
|
| 36 |
+
if not api_key:
|
| 37 |
+
raise ValueError("GROQ_API_KEY not set. Add it to your .env file.")
|
| 38 |
+
logger.info(f"Initializing Groq LLM: {key}")
|
| 39 |
+
_LLM_CACHE[key] = ChatGroq(
|
| 40 |
+
groq_api_key=api_key,
|
| 41 |
+
model_name=key,
|
| 42 |
+
temperature=0.1,
|
| 43 |
+
max_tokens=2048,
|
| 44 |
+
)
|
| 45 |
+
return _LLM_CACHE[key]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class MathAIEngine:
|
| 49 |
+
"""Orchestrates RAG retrieval, symbolic math, and LLM calls for math tutoring."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, vector_store=None, session_id: str = "default"):
|
| 52 |
+
self.llm = self._init_llm()
|
| 53 |
+
self.vector_store = vector_store
|
| 54 |
+
self.memory = MongoDBChatMemory(session_id=session_id)
|
| 55 |
+
self.symbolic = SymbolicMathEngine()
|
| 56 |
+
self.session_id = session_id
|
| 57 |
+
|
| 58 |
+
def _init_llm(self):
|
| 59 |
+
"""Initialize the primary LLM model."""
|
| 60 |
+
return _get_llm(settings.groq_model_fallbacks[0])
|
| 61 |
+
|
| 62 |
+
def _retrieve_context(self, query: str) -> Tuple[list, str]:
|
| 63 |
+
"""Retrieve relevant documents from the vector store."""
|
| 64 |
+
if not self.vector_store or not self.vector_store.is_ready():
|
| 65 |
+
return [], "No knowledge base available. Using general mathematical knowledge."
|
| 66 |
+
docs = self.vector_store.similarity_search(query, k=3)
|
| 67 |
+
if not docs:
|
| 68 |
+
return [], "No specific context found."
|
| 69 |
+
parts = [f"[Reference {i+1} - {d.metadata.get('topic','math')}]\n{d.page_content}"
|
| 70 |
+
for i, d in enumerate(docs)]
|
| 71 |
+
return docs, "\n\n---\n\n".join(parts)
|
| 72 |
+
|
| 73 |
+
def _symbolic_hint(self, query: str) -> Optional[str]:
|
| 74 |
+
"""Try to get a symbolic verification hint for the query."""
|
| 75 |
+
ql = query.lower()
|
| 76 |
+
for pattern, action in [
|
| 77 |
+
(r"(?:differentiate|derivative of|d/dx)\s+(.+?)(?:\s+with respect|\s*$)", "diff"),
|
| 78 |
+
(r"(?:integrate|integral of)\s+(.+?)(?:\s+with respect|\s+dx|\s*$)", "int"),
|
| 79 |
+
(r"solve\s+(.+?)\s+(?:for|=)", "solve"),
|
| 80 |
+
]:
|
| 81 |
+
m = re.search(pattern, ql)
|
| 82 |
+
if m:
|
| 83 |
+
expr = m.group(1).strip()
|
| 84 |
+
result = (self.symbolic.differentiate(expr) if action == "diff"
|
| 85 |
+
else self.symbolic.integrate(expr) if action == "int"
|
| 86 |
+
else self.symbolic.solve_equation(expr))
|
| 87 |
+
if result:
|
| 88 |
+
return f"[Symbolic verification: {result}]"
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
def query(self, user_input: str) -> Dict[str, Any]:
|
| 92 |
+
"""Process a user math query through the full RAG pipeline.
|
| 93 |
+
|
| 94 |
+
Flow: symbolic hint → vector retrieval → chat history →
|
| 95 |
+
Gemini (primary) or Groq (fallback) → store response.
|
| 96 |
+
"""
|
| 97 |
+
hint = self._symbolic_hint(user_input)
|
| 98 |
+
source_docs, context = self._retrieve_context(user_input)
|
| 99 |
+
chat_history = self.memory.get_langchain_messages(limit=4)
|
| 100 |
+
|
| 101 |
+
# Store human message BEFORE LLM call so history order is correct
|
| 102 |
+
self.memory.add_message("human", user_input)
|
| 103 |
+
|
| 104 |
+
# ── Try Gemini first (Phase 2) ─────────────────────────────────
|
| 105 |
+
if settings.use_gemini and settings.gemini_api_key:
|
| 106 |
+
try:
|
| 107 |
+
from backend.src.services.gemini_service import GeminiService
|
| 108 |
+
gemini = GeminiService()
|
| 109 |
+
answer = gemini.query(user_input, context=context, chat_history=chat_history)
|
| 110 |
+
if answer and not answer.startswith("⚠️"):
|
| 111 |
+
self.memory.add_message("assistant", answer)
|
| 112 |
+
sources = [{"topic": d.metadata.get("topic", "unknown"),
|
| 113 |
+
"source": d.metadata.get("source", "kb"),
|
| 114 |
+
"difficulty": d.metadata.get("difficulty", "unknown")}
|
| 115 |
+
for d in source_docs]
|
| 116 |
+
return {"answer": answer, "sources": sources, "symbolic_hint": hint,
|
| 117 |
+
"session_id": self.session_id, "context_docs": len(source_docs)}
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.warning(f"Gemini failed, falling back to Groq: {e}")
|
| 120 |
+
|
| 121 |
+
# ── Groq Fallback ──────────────────────────────────────────────
|
| 122 |
+
# Build messages list manually — NEVER pass math content through
|
| 123 |
+
# LangChain format_messages(), because curly braces in math break templates
|
| 124 |
+
system_text = SYSTEM_TEMPLATE.replace("{context}", context)
|
| 125 |
+
llm_messages = [SystemMessage(content=system_text)]
|
| 126 |
+
for msg in chat_history:
|
| 127 |
+
llm_messages.append(msg)
|
| 128 |
+
llm_messages.append(HumanMessage(content=user_input))
|
| 129 |
+
|
| 130 |
+
# Auto-retry with model fallback
|
| 131 |
+
answer = None
|
| 132 |
+
last_error = None
|
| 133 |
+
used_models = []
|
| 134 |
+
for model_name in settings.groq_model_fallbacks:
|
| 135 |
+
if model_name in used_models:
|
| 136 |
+
continue
|
| 137 |
+
used_models.append(model_name)
|
| 138 |
+
llm = _get_llm(model_name)
|
| 139 |
+
for _attempt in range(2):
|
| 140 |
+
try:
|
| 141 |
+
raw = llm.invoke(llm_messages).content
|
| 142 |
+
if raw and not any(p in raw.lower() for p in
|
| 143 |
+
["rate limit", "too many requests", "service unavailable"]):
|
| 144 |
+
answer = raw
|
| 145 |
+
if model_name != settings.groq_model_fallbacks[0]:
|
| 146 |
+
answer = f"*(Using fallback model: {model_name})*\n\n" + answer
|
| 147 |
+
break
|
| 148 |
+
else:
|
| 149 |
+
raise Exception(raw or "Empty response")
|
| 150 |
+
except Exception as e:
|
| 151 |
+
last_error = e
|
| 152 |
+
err = str(e).lower()
|
| 153 |
+
full_err = str(e)
|
| 154 |
+
logger.warning(f"Model {model_name} attempt {_attempt+1} failed: {e}")
|
| 155 |
+
if "per day" in full_err or "tokens per day" in full_err:
|
| 156 |
+
logger.info(f"Daily limit on {model_name}, trying next model...")
|
| 157 |
+
break
|
| 158 |
+
elif "429" in full_err or "rate_limit" in err:
|
| 159 |
+
time.sleep(2 ** _attempt)
|
| 160 |
+
continue
|
| 161 |
+
elif "timeout" in err or "connect" in err or "503" in err:
|
| 162 |
+
time.sleep(1)
|
| 163 |
+
continue
|
| 164 |
+
else:
|
| 165 |
+
break
|
| 166 |
+
if answer:
|
| 167 |
+
break
|
| 168 |
+
|
| 169 |
+
if answer is None:
|
| 170 |
+
full_err = str(last_error)
|
| 171 |
+
err = full_err.lower()
|
| 172 |
+
logger.error(f"LLM failed: [{type(last_error).__name__}] {full_err}")
|
| 173 |
+
|
| 174 |
+
if "401" in full_err or "invalid_api_key" in err:
|
| 175 |
+
answer = "⚠️ Invalid API key. Check GROQ_API_KEY in your .env file."
|
| 176 |
+
elif "429" in full_err or "rate_limit_exceeded" in err:
|
| 177 |
+
retry_match = re.search(r'try again in (.+?)\.', full_err)
|
| 178 |
+
retry_info = f" Groq says: try again in **{retry_match.group(1)}**." if retry_match else ""
|
| 179 |
+
if "per day" in full_err or "tokens per day" in full_err or "TPD" in full_err:
|
| 180 |
+
answer = (f"⚠️ **Daily token limit reached** (Groq free tier: 100,000 tokens/day).{retry_info}\n\n"
|
| 181 |
+
f"To keep using the app now, change your `.env`:\n```\nLLM_MODEL=llama3-8b-8192\n```\n"
|
| 182 |
+
f"Then restart Streamlit. The 8B model has a separate 500k/day quota.")
|
| 183 |
+
else:
|
| 184 |
+
answer = f"⚠️ **Rate limit hit** (too many requests per minute).{retry_info} Wait 20–30 seconds and try again."
|
| 185 |
+
elif "context_length" in err or ("context" in err and "length" in err):
|
| 186 |
+
answer = "⚠️ Question + context too long. Try a shorter question."
|
| 187 |
+
elif "connect" in err or "connection" in err:
|
| 188 |
+
answer = "⚠️ Cannot reach Groq API. Check your internet connection."
|
| 189 |
+
elif "timeout" in err:
|
| 190 |
+
answer = "⚠️ Request timed out. Try again."
|
| 191 |
+
else:
|
| 192 |
+
answer = f"⚠️ Error ({type(last_error).__name__}): {full_err}"
|
| 193 |
+
|
| 194 |
+
self.memory.add_message("assistant", answer)
|
| 195 |
+
|
| 196 |
+
sources = [{"topic": d.metadata.get("topic", "unknown"),
|
| 197 |
+
"source": d.metadata.get("source", "kb"),
|
| 198 |
+
"difficulty": d.metadata.get("difficulty", "unknown")}
|
| 199 |
+
for d in source_docs]
|
| 200 |
+
|
| 201 |
+
return {"answer": answer, "sources": sources, "symbolic_hint": hint,
|
| 202 |
+
"session_id": self.session_id, "context_docs": len(source_docs)}
|
| 203 |
+
|
| 204 |
+
def clear_memory(self):
|
| 205 |
+
"""Clear chat history for this session."""
|
| 206 |
+
self.memory.clear_history()
|
| 207 |
+
|
| 208 |
+
def get_history(self):
|
| 209 |
+
"""Get full chat history for this session."""
|
| 210 |
+
return self.memory.get_history(limit=50)
|
src/services/memory_service.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat Memory Service — MongoDB-backed persistent chat history.
|
| 3 |
+
|
| 4 |
+
Extracted from main.py L525-L582. Provides per-session chat history
|
| 5 |
+
with MongoDB persistence and in-memory fallback when MongoDB is unavailable.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
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
|
| 16 |
+
except ImportError:
|
| 17 |
+
from langchain.schema import HumanMessage, AIMessage
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("math_assistant.memory")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MongoDBChatMemory:
|
| 23 |
+
"""Per-session chat memory with MongoDB persistence and in-memory fallback."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, session_id: str = "default"):
|
| 26 |
+
self.session_id = session_id
|
| 27 |
+
self.collection = None
|
| 28 |
+
self._memory: List[Dict] = []
|
| 29 |
+
self._connect()
|
| 30 |
+
|
| 31 |
+
def _connect(self):
|
| 32 |
+
if not settings.mongodb_uri:
|
| 33 |
+
return
|
| 34 |
+
try:
|
| 35 |
+
from pymongo import MongoClient
|
| 36 |
+
client = MongoClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000)
|
| 37 |
+
client.admin.command("ping")
|
| 38 |
+
self.collection = client[settings.mongodb_db_name][settings.mongodb_collection]
|
| 39 |
+
logger.info("MongoDB connected")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.warning(f"MongoDB unavailable ({e}), using in-memory history")
|
| 42 |
+
|
| 43 |
+
def add_message(self, role: str, content: str):
|
| 44 |
+
"""Add a message to chat history."""
|
| 45 |
+
msg = {
|
| 46 |
+
"session_id": self.session_id,
|
| 47 |
+
"role": role,
|
| 48 |
+
"content": content,
|
| 49 |
+
"timestamp": datetime.now(timezone.utc),
|
| 50 |
+
}
|
| 51 |
+
if self.collection is not None:
|
| 52 |
+
try:
|
| 53 |
+
self.collection.insert_one(msg)
|
| 54 |
+
return
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
self._memory.append(msg)
|
| 58 |
+
|
| 59 |
+
def get_history(self, limit: int = 20) -> List[Dict]:
|
| 60 |
+
"""Retrieve recent chat history for this session."""
|
| 61 |
+
if self.collection is not None:
|
| 62 |
+
try:
|
| 63 |
+
msgs = list(
|
| 64 |
+
self.collection.find({"session_id": self.session_id})
|
| 65 |
+
.sort("timestamp", -1)
|
| 66 |
+
.limit(limit)
|
| 67 |
+
)
|
| 68 |
+
msgs.reverse()
|
| 69 |
+
return msgs
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
return self._memory[-limit:]
|
| 73 |
+
|
| 74 |
+
def get_langchain_messages(self, limit: int = 10):
|
| 75 |
+
"""Convert chat history to LangChain message objects."""
|
| 76 |
+
history = self.get_history(limit)
|
| 77 |
+
result = []
|
| 78 |
+
for msg in history:
|
| 79 |
+
if msg["role"] == "human":
|
| 80 |
+
result.append(HumanMessage(content=msg["content"]))
|
| 81 |
+
elif msg["role"] == "assistant":
|
| 82 |
+
result.append(AIMessage(content=msg["content"]))
|
| 83 |
+
return result
|
| 84 |
+
|
| 85 |
+
def clear_history(self):
|
| 86 |
+
"""Clear all messages for this session."""
|
| 87 |
+
if self.collection is not None:
|
| 88 |
+
try:
|
| 89 |
+
self.collection.delete_many({"session_id": self.session_id})
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
self._memory.clear()
|
src/services/vector_service.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vector Store Service — ChromaDB/FAISS vector database operations.
|
| 3 |
+
|
| 4 |
+
Extracted from main.py L276-L397. Manages embedding model loading,
|
| 5 |
+
vector store creation/loading, similarity search, and the full
|
| 6 |
+
knowledge base build pipeline.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, List
|
| 12 |
+
|
| 13 |
+
from backend.src.config import settings
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("math_assistant.vector")
|
| 16 |
+
|
| 17 |
+
_EMBEDDINGS_CACHE = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_embeddings():
|
| 21 |
+
"""Get or create the singleton embedding model instance."""
|
| 22 |
+
if "model" not in _EMBEDDINGS_CACHE:
|
| 23 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 24 |
+
logger.info(f"Loading embedding model: {settings.embedding_model}")
|
| 25 |
+
_EMBEDDINGS_CACHE["model"] = HuggingFaceEmbeddings(
|
| 26 |
+
model_name=settings.embedding_model,
|
| 27 |
+
model_kwargs={"device": "cpu"},
|
| 28 |
+
encode_kwargs={"normalize_embeddings": True},
|
| 29 |
+
)
|
| 30 |
+
return _EMBEDDINGS_CACHE["model"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class MathVectorStore:
|
| 34 |
+
"""Vector store abstraction supporting ChromaDB (primary) and FAISS (fallback)."""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
self.embeddings = get_embeddings()
|
| 38 |
+
self.vectorstore = None
|
| 39 |
+
self.db_type = settings.vector_db_type
|
| 40 |
+
self._load_existing()
|
| 41 |
+
|
| 42 |
+
def _load_existing(self):
|
| 43 |
+
if self.db_type == "chroma":
|
| 44 |
+
self._try_chroma()
|
| 45 |
+
else:
|
| 46 |
+
self._try_faiss()
|
| 47 |
+
|
| 48 |
+
def _try_chroma(self, documents=None):
|
| 49 |
+
try:
|
| 50 |
+
from langchain_community.vectorstores import Chroma
|
| 51 |
+
persist_path = Path(settings.chroma_persist_dir)
|
| 52 |
+
persist_path.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
if documents:
|
| 54 |
+
self.vectorstore = Chroma.from_documents(
|
| 55 |
+
documents=documents, embedding=self.embeddings,
|
| 56 |
+
collection_name=settings.collection_name,
|
| 57 |
+
persist_directory=str(persist_path))
|
| 58 |
+
logger.info("ChromaDB created.")
|
| 59 |
+
elif list(persist_path.glob("*.sqlite3")):
|
| 60 |
+
self.vectorstore = Chroma(
|
| 61 |
+
collection_name=settings.collection_name,
|
| 62 |
+
embedding_function=self.embeddings,
|
| 63 |
+
persist_directory=str(persist_path))
|
| 64 |
+
logger.info(f"ChromaDB loaded ({self.vectorstore._collection.count()} docs)")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.warning(f"ChromaDB failed ({type(e).__name__}: {e}), switching to FAISS")
|
| 67 |
+
self.db_type = "faiss"
|
| 68 |
+
if documents:
|
| 69 |
+
self._try_faiss(documents)
|
| 70 |
+
|
| 71 |
+
def _try_faiss(self, documents=None):
|
| 72 |
+
try:
|
| 73 |
+
from langchain_community.vectorstores import FAISS
|
| 74 |
+
index_path = Path(settings.faiss_index_path)
|
| 75 |
+
if documents:
|
| 76 |
+
self.vectorstore = FAISS.from_documents(documents, self.embeddings)
|
| 77 |
+
index_path.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
self.vectorstore.save_local(str(index_path))
|
| 79 |
+
logger.info(f"FAISS saved to {index_path}")
|
| 80 |
+
elif index_path.exists() and any(index_path.iterdir()):
|
| 81 |
+
self.vectorstore = FAISS.load_local(
|
| 82 |
+
str(index_path), self.embeddings,
|
| 83 |
+
allow_dangerous_deserialization=True)
|
| 84 |
+
logger.info("FAISS loaded.")
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"FAISS failed: {e}")
|
| 87 |
+
|
| 88 |
+
def build_knowledge_base(self, documents):
|
| 89 |
+
"""Build the vector index from a list of Document chunks."""
|
| 90 |
+
logger.info(f"Building knowledge base with {len(documents)} chunks...")
|
| 91 |
+
if self.db_type == "chroma":
|
| 92 |
+
self._try_chroma(documents)
|
| 93 |
+
else:
|
| 94 |
+
self._try_faiss(documents)
|
| 95 |
+
logger.info("Knowledge base ready.")
|
| 96 |
+
|
| 97 |
+
def add_documents(self, documents):
|
| 98 |
+
"""Add documents to an existing vector store, or create one."""
|
| 99 |
+
if self.vectorstore is None:
|
| 100 |
+
self.build_knowledge_base(documents)
|
| 101 |
+
else:
|
| 102 |
+
self.vectorstore.add_documents(documents)
|
| 103 |
+
|
| 104 |
+
def similarity_search(self, query: str, k: int = None, filter_topic: str = None):
|
| 105 |
+
"""Search for similar documents by query string."""
|
| 106 |
+
k = k or settings.top_k_results
|
| 107 |
+
if self.vectorstore is None:
|
| 108 |
+
return []
|
| 109 |
+
try:
|
| 110 |
+
if filter_topic and self.db_type == "chroma":
|
| 111 |
+
return self.vectorstore.similarity_search(query, k=k, filter={"topic": filter_topic})
|
| 112 |
+
return self.vectorstore.similarity_search(query, k=k)
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"Search failed: {e}")
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
def as_retriever(self, k: int = None):
|
| 118 |
+
"""Return a LangChain retriever interface."""
|
| 119 |
+
k = k or settings.top_k_results
|
| 120 |
+
return self.vectorstore.as_retriever(search_kwargs={"k": k}) if self.vectorstore else None
|
| 121 |
+
|
| 122 |
+
def get_document_count(self) -> int:
|
| 123 |
+
"""Get the number of indexed documents."""
|
| 124 |
+
if self.vectorstore is None:
|
| 125 |
+
return 0
|
| 126 |
+
try:
|
| 127 |
+
return (self.vectorstore._collection.count() if self.db_type == "chroma"
|
| 128 |
+
else self.vectorstore.index.ntotal)
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"get_document_count failed: {e}")
|
| 131 |
+
return 0
|
| 132 |
+
|
| 133 |
+
def is_ready(self) -> bool:
|
| 134 |
+
"""Check if the vector store is loaded and has documents."""
|
| 135 |
+
return self.vectorstore is not None and self.get_document_count() > 0
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
_PIPELINE_CACHE = {}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def build_pipeline(pdf_paths=None, urls=None, text_paths=None, force_rebuild=False) -> MathVectorStore:
|
| 142 |
+
"""Build or load the full knowledge base pipeline (cached per process)."""
|
| 143 |
+
from backend.src.math.knowledge_indexer import MathDataLoader, MathDataPreprocessor, MathTextSplitter
|
| 144 |
+
|
| 145 |
+
if "store" in _PIPELINE_CACHE and not force_rebuild:
|
| 146 |
+
return _PIPELINE_CACHE["store"]
|
| 147 |
+
store = MathVectorStore()
|
| 148 |
+
if store.is_ready() and not force_rebuild:
|
| 149 |
+
logger.info(f"Knowledge base already built ({store.get_document_count()} docs).")
|
| 150 |
+
_PIPELINE_CACHE["store"] = store
|
| 151 |
+
return store
|
| 152 |
+
raw_docs = MathDataLoader().load_all(pdf_paths=pdf_paths or [], urls=urls or [], text_paths=text_paths or [])
|
| 153 |
+
clean_docs = MathDataPreprocessor().preprocess_documents(raw_docs)
|
| 154 |
+
chunks = MathTextSplitter().split_documents(clean_docs)
|
| 155 |
+
store.build_knowledge_base(chunks)
|
| 156 |
+
_PIPELINE_CACHE["store"] = store
|
| 157 |
+
return store
|