Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 96 additions & 32 deletions backend/api/routes/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,37 @@
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")

router = APIRouter()


# -- 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(
...,
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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"},
},
),
)
Expand All @@ -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,
)
2 changes: 2 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 14 additions & 7 deletions backend/data/chunks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand All @@ -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"
}
},
{
Expand All @@ -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"
}
},
{
Expand All @@ -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"
}
},
{
Expand All @@ -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"
}
},
{
Expand All @@ -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"
}
},
{
Expand All @@ -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."
}
}
]
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ uvicorn
python-jose
pyjwt
slowapi
scalar_fastapi
scalar_fastapi

redis
Loading
Loading