From 9434b9ff2d6eba8c3bac6b0cc6867e83b7e1048a Mon Sep 17 00:00:00 2001 From: DevDoshi19 Date: Fri, 10 Jul 2026 12:19:06 +0530 Subject: [PATCH] phase 15b: add Redis caching to query endpoint - docker-compose.yml: added Redis service on host port 6380 - config.py: added redis_url setting - requirements.txt: added redis - query.py: cache check before pipeline, cache write after - cache key: md5(question.lower().strip()) - TTL: 24 hours - Redis failures are non-fatal, pipeline runs normally on error - QueryResponse now includes cached: bool field --- backend/api/routes/query.py | 128 +++++++++++++++++++++++++++--------- backend/app/config.py | 2 + backend/data/chunks.json | 21 ++++-- backend/requirements.txt | 4 +- docker-compose.yml | 91 +++++++++++++++---------- 5 files changed, 172 insertions(+), 74 deletions(-) diff --git a/backend/api/routes/query.py b/backend/api/routes/query.py index 5404b3c..31507a0 100644 --- a/backend/api/routes/query.py +++ b/backend/api/routes/query.py @@ -4,21 +4,30 @@ POST /api/query — the main endpoint. Receives a question, runs the LangGraph pipeline, returns structured JSON. +Redis caching added in Phase 15b: + - Cache key: md5(question.lower().strip()) + - Cache hit: return cached response instantly, zero LLM calls + - Cache miss: run full pipeline, store result with 24hr TTL + - Redis failure: log warning and fall through to pipeline (never crash) + Protected by: - SlowAPI rate limit : 3 requests / minute / IP - Input validation : Pydantic rejects malformed requests before they hit the pipeline """ import asyncio +import hashlib +import json import logging +import redis from fastapi import APIRouter, Request, status from fastapi.responses import JSONResponse from langchain_core.tracers.context import tracing_v2_enabled from pydantic import BaseModel, Field +from app.config import settings from app.state import RAGState -# from api.middleware.rate_limit import limiter logger = logging.getLogger("vaultmind.api.query") @@ -26,10 +35,6 @@ # -- Request model -- -# Pydantic validates this before your handler runs. -# `min_length=1` rejects empty strings — no need to check manually. -# `max_length=500` prevents abuse — nobody needs a 5000-character resume question. -# `Field(...)` lets you add metadata that shows up in /docs. class QueryRequest(BaseModel): question: str = Field( ..., @@ -41,8 +46,6 @@ class QueryRequest(BaseModel): # -- Response model -- -# Every field the frontend might need. -# `None` defaults mean the field is optional — blocked queries won't have confidence scores. class QueryResponse(BaseModel): answer: str confidence_score: float | None = None @@ -51,7 +54,54 @@ class QueryResponse(BaseModel): total_tokens: int = 0 estimated_cost: float = 0.0 retrieval_status: str = "" - retrieved_chunks: int = 0 # count only, not the raw text — keeps response small + retrieved_chunks: int = 0 + cached: bool = False + + +# -- Cache helpers -- +def _cache_key(question: str) -> str: + # Normalize the question before hashing so "What are his skills?" + # and "what are his skills?" hit the same cache entry. + normalized = question.lower().strip() + return f"vaultmind:query:{hashlib.md5(normalized.encode()).hexdigest()}" + + +def _get_redis_client(): + # We create a fresh client per request — Redis client is lightweight. + # In Phase 16 we'll move this to a connection pool on app.state. + return redis.from_url(settings.redis_url, decode_responses=True) + + +def _get_cached_response(question: str) -> dict | None: + # Returns cached response dict if found, None on miss or Redis failure. + # Redis failure is non-fatal — we always fall through to the pipeline. + try: + client = _get_redis_client() + key = _cache_key(question) + cached = client.get(key) + if cached: + logger.info(f"⚡ Cache hit for question: '{question[:50]}'") + return json.loads(cached) + except Exception as e: + logger.warning(f"Redis get failed — falling through to pipeline: {e}") + return None + + +def _set_cached_response(question: str, response: dict) -> None: + # Stores response in Redis with 24hr TTL. + # Failure is non-fatal — cache miss on next request is acceptable. + try: + client = _get_redis_client() + key = _cache_key(question) + client.setex( + name=key, + time=86400, # 24 hours in seconds + value=json.dumps(response), + ) + logger.info(f"Cached response for question: '{question[:50]}'") + except Exception as e: + logger.warning(f"Redis set failed — response not cached: {e}") + # -- Endpoint -- @router.post( @@ -61,13 +111,26 @@ class QueryResponse(BaseModel): description="Send a natural language question. Returns an answer grounded in Dev Doshi's resume.", status_code=status.HTTP_200_OK, ) -# @limiter.limit("10/minute") # SlowAPI checks IP counter before handler runs async def query_endpoint(request: Request, body: QueryRequest) -> JSONResponse: - logger.info(f"Query received: '{body.question[:60]}...' " if len(body.question) > 60 else f"Query received: '{body.question}'") + logger.info( + f"Query received: '{body.question[:60]}...'" + if len(body.question) > 60 + else f"Query received: '{body.question}'" + ) + + # -- Cache check -- + # Check Redis before touching the pipeline. + # Cache hit = instant response, zero OpenAI calls, zero cost. + cached = _get_cached_response(body.question) + if cached: + cached["cached"] = True + return JSONResponse( + status_code=status.HTTP_200_OK, + content=cached, + ) # -- Build initial LangGraph state -- - # Same structure as main.py / streamlit_app.py — nothing changes here. initial_state: RAGState = { "question": body.question, "question_is_relevant": False, @@ -84,24 +147,18 @@ async def query_endpoint(request: Request, body: QueryRequest) -> JSONResponse: "output_flagged": False, } - # -- Get the compiled graph from app state -- - # Set once at startup by lifespan in main.py — never recompiled per request. graph = request.app.state.graph - # -- Run LangGraph in a thread pool -- - # graph.invoke() is synchronous and blocks for 3-5 seconds while nodes execute. - # Calling it directly inside `async def` would freeze the entire FastAPI event loop — - # no other requests could be processed until this one finishes. - # run_in_executor offloads it to a background thread, keeping the event loop free. + # -- Run LangGraph pipeline -- with tracing_v2_enabled(project_name="vaultmind"): result: RAGState = await asyncio.get_event_loop().run_in_executor( - None, # None = use Python's default ThreadPoolExecutor + None, lambda: graph.invoke( initial_state, config={ "run_name": f"VaultMind | {body.question[:50]}", "tags": ["production", "resume-rag", "fastapi"], - "metadata": {"phase": "12", "retrieval": "hybrid", "llm": "gpt-4o-mini"}, + "metadata": {"phase": "15b", "retrieval": "hybrid", "llm": "gpt-4o-mini"}, }, ), ) @@ -113,18 +170,25 @@ async def query_endpoint(request: Request, body: QueryRequest) -> JSONResponse: ) # -- Build response -- - # We don't send raw retrieved_docs to the frontend — those can be large. - # Instead we send the count. Frontend can request chunks separately if needed (Phase 16). + response = QueryResponse( + answer=result.get("answer", ""), + confidence_score=result.get("confidence_score"), + input_blocked=result.get("input_blocked", False), + output_flagged=result.get("output_flagged", False), + total_tokens=result.get("total_tokens", 0), + estimated_cost=result.get("estimated_cost", 0.0), + retrieval_status=result.get("retrieval_status", ""), + retrieved_chunks=len(result.get("retrieved_docs", [])), + cached=False, + ).model_dump() + + # -- Store in cache -- + # Only cache successful, non-blocked responses. + # Blocked queries are cheap (no retrieval) so caching them isn't worth it. + if not result.get("input_blocked", False): + _set_cached_response(body.question, response) + return JSONResponse( status_code=status.HTTP_200_OK, - content=QueryResponse( - answer=result.get("answer", ""), - confidence_score=result.get("confidence_score"), - input_blocked=result.get("input_blocked", False), - output_flagged=result.get("output_flagged", False), - total_tokens=result.get("total_tokens", 0), - estimated_cost=result.get("estimated_cost", 0.0), - retrieval_status=result.get("retrieval_status", ""), - retrieved_chunks=len(result.get("retrieved_docs", [])), - ).model_dump(), + content=response, ) \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index b2db9d3..1c13037 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,8 @@ class Settings(BaseSettings): llm_timeout: float = 30.0 max_llm_retries: int = 3 + redis_url: str = "redis://localhost:6379" + class Config: env_file = ".env" extra = "ignore" diff --git a/backend/data/chunks.json b/backend/data/chunks.json index c822fc9..d82f1f6 100644 --- a/backend/data/chunks.json +++ b/backend/data/chunks.json @@ -15,7 +15,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "DEV DOSHI\nGithub\u00b7LinkedIn\u00b7devdoshi1927@gmail.com\u00b7+91 97249 31330\nSummary\nAI & Data Science undergraduate (CGPA9.37) specializing in LLM applications, multi-agent systems, and\nautomation pipelines. Experienced in designing and deploying production-ready AI tools using LangChain,\nLangGraph, FastMCP, and n8n - focused on solutions that create real business value.\nSkills\nAI / LLMLangChain, LangGraph, LangSmith, FastMCP, RAG, Prompt Engineering,\nMulti-Agent Systems, Ollama" } }, { @@ -34,7 +35,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "Multi-Agent Systems, Ollama\nVector StoresChromaDB, FAISS\nAutomationn8n, Agentic Workflow Design, Google Sheets API\nBackend / APIsFastAPI, Streamlit, Docker, SQLite (async) , Pydantic\nML / DLScikit-learn, TensorFlow, ANN, CNN, Supervised & Unsupervised Learning\nLanguagesPython, SQL, C++\nToolsMulti-LLM Integration (Gemini, OpenAI, Groq), Git, GitHub\nProjects\nAgentic Research & Analysis Pipeline Code\nn8n \u00b7 Research / Logical / Explainer Agents \u00b7 Google Sheets \u00b7 Chat UI" } }, { @@ -53,7 +55,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "n8n \u00b7 Research / Logical / Explainer Agents \u00b7 Google Sheets \u00b7 Chat UI\n\u2022 Routes queries across 3 specialized agents via intent classification; retrieves company financial docs to\neliminate hallucinations.\n\u2022 Evaluation scores, performance metrics, and errors auto-logged to Google Sheets for live monitoring.\nExpense Tracker MCP Server Code\nFastMCP \u00b7 SQLite (async) \u00b7 Claude MCP Connector \u00b7 FastMCP Cloud" } }, { @@ -72,7 +75,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "Expense Tracker MCP Server Code\nFastMCP \u00b7 SQLite (async) \u00b7 Claude MCP Connector \u00b7 FastMCP Cloud\n\u2022 Async MCP server with 6+ typed tools (add, query , summarise, budget alerts) exposed directly to Claude.\n\u2022 Handles concurrent multi-user sessions via async SQLite; deployed on FastMCP cloud with zero local\nsetup.\n\u2022 Connect instantly by adding the hosted URL to any Claude MCP client \u2013 no installation required.\nCareerForge AI \u2014 Full-Stack Career Intelligence Platform Code \u00b7 Live Demo" } }, { @@ -91,7 +95,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "CareerForge AI \u2014 Full-Stack Career Intelligence Platform Code \u00b7 Live Demo\nLangChain \u00b7 FastAPI \u00b7 Streamlit \u00b7 SQLite \u00b7 Gemini API\n\u2022 ATS scanner evaluating resumes against 100+ keywords, returns score, salary estimate, gaps, and tips.\n\u2022 Dual-mode resume builder (Modern + Classic) generating a download-ready PDF in under 20 seconds.\n\u2022 Context-aware chatbot for resume Q&A with persistent multi-user session storage.\nPersistent Chat Assistant with Live Tools & Memory Code \u00b7 Live Demo" } }, { @@ -110,7 +115,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "Persistent Chat Assistant with Live Tools & Memory Code \u00b7 Live Demo\nLangGraph \u00b7 SQLite (async) \u00b7 Gemini API \u00b7 Streamlit \u00b7 LangSmith\n\u2022 Live tool use: real-time stock prices, weather, and context-aware memory across sessions.\n\u2022 Auto-generates chat titles; full history persisted in async SQLite survives restarts and reloads.\nEducation & Certifications\n2023-27B.Tech in AI & Data Science- ADIT, CVM University CGPA:9.37(Sem 1\u20135)\n2021-23 Higher Secondary School (Science) - Adarsh Vidhyalaya" } }, { @@ -129,7 +135,8 @@ "source": "./data/resume.pdf", "total_pages": 1, "page": 0, - "page_label": "1" + "page_label": "1", + "text": "2021-23 Higher Secondary School (Science) - Adarsh Vidhyalaya\n2025SAP Foundation Course- Edunet Foundation\n2025Python Programming- HackerRank\nActivities & Interests\n\u2022 Participated inSmart India Hackathon&College Ideathonin 2024 ,2025\n\u2022 Creating content onmotivation, books, and mindset; independently studyingpersonal finance and\nfundamental analysis." } } ] \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index e00ffa7..da811b2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -34,4 +34,6 @@ uvicorn python-jose pyjwt slowapi -scalar_fastapi \ No newline at end of file +scalar_fastapi + +redis \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7b7d1c9..06c6de1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,58 +2,86 @@ # Place this at the project root — VaultMind/docker-compose.yml # # Commands: -# docker compose up --build → build images and start both containers +# docker compose up --build → build images and start all containers # docker compose up → start with existing images (no rebuild) # docker compose down → stop and remove containers -# docker compose logs -f → stream logs from both services +# docker compose logs -f → stream logs from all services # docker compose logs -f backend → logs from backend only services: - # ── Backend ──────────────────────────────────────────────────────────────── + # -- Redis -- + redis: + image: redis:7-alpine # same image your other projects use + container_name: vaultmind-redis + + # Redis default port. Backend reaches it at redis://redis:6379 + # "redis" is the service name — Docker DNS resolves it inside the network. + ports: + - "6380:6379" # host 6380 to avoid conflict with other projects + + # Redis data persists across restarts via this volume. + # Without it, cache is wiped every time the container restarts. + volumes: + - redis-data:/data + + # appendonly yes — writes every operation to disk for durability. + # For a cache this is optional, but good practice. + command: redis-server --appendonly yes + + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + restart: unless-stopped + networks: + - vaultmind-network + + + # -- Backend -- backend: build: - context: ./backend # Docker sees everything inside backend/ - dockerfile: Dockerfile # uses backend/Dockerfile + context: ./backend + dockerfile: Dockerfile container_name: vaultmind-backend - # Passes every variable from .env into the container at runtime. - # Secrets never baked into the image — they live only on your machine. env_file: - .env - # Publish port 8000 inside the container to port 8000 on your machine. - # Format: "host_port:container_port" - # Access the API at http://localhost:8000 from your browser or curl. + # REDIS_URL injected here — overrides anything in .env. + # Backend reaches Redis by service name "redis" inside Docker network. + environment: + - REDIS_URL=redis://redis:6379 + ports: - "8000:8000" - # Mount host directories into the container. - # chroma_db/ and data/ survive container restarts and rebuilds. - # Without volumes, every `docker compose down` wipes your vector store. + # Removed chroma_db volume — Pinecone is external, no local disk needed. + # Only data/ remains for chunks.json (BM25 still needs raw text). volumes: - - ./backend/chroma_db:/app/chroma_db # vector store persists - - ./backend/data:/app/data # resume.pdf + chunks.json + - ./backend/data:/app/data - # Docker checks this every 30s using the /health endpoint we built. - # unhealthy containers are visible in `docker compose ps`. healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s retries: 3 - start_period: 15s # grace period for graph compilation + start_period: 15s - # Always restart if the container crashes — except if you manually stop it. - restart: unless-stopped + # Wait for Redis to be healthy before starting backend. + depends_on: + redis: + condition: service_healthy - # Connect to the shared private network. + restart: unless-stopped networks: - vaultmind-network - # ── Frontend ─────────────────────────────────────────────────────────────── + # -- Frontend -- frontend: build: context: ./frontend @@ -61,19 +89,12 @@ services: container_name: vaultmind-frontend - # BACKEND_URL override — inside Docker, containers reach each other - # by service name, not localhost. "backend" is the service name above. - # This overrides whatever BACKEND_URL is set to in streamlit_app.py. environment: - BACKEND_URL=http://backend:8000 ports: - "8501:8501" - # Don't start frontend until backend container is running. - # Note: "running" doesn't mean "healthy" — just that the process started. - # For strict ordering (wait for /health to pass), you'd use a wait script. - # This is sufficient for development. depends_on: - backend @@ -85,14 +106,16 @@ services: start_period: 10s restart: unless-stopped - networks: - vaultmind-network -# ── Network ──────────────────────────────────────────────────────────────── -# bridge is the default driver — creates a private network between containers. -# Both services are on this network so frontend can call backend by service name. +# -- Network -- networks: vaultmind-network: - driver: bridge \ No newline at end of file + driver: bridge + +# -- Volumes -- +# Named volume for Redis persistence — managed by Docker, not a host folder. +volumes: + redis-data: \ No newline at end of file