False45 commited on
Commit
c75184e
·
0 Parent(s):
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