build-small-hackathon / backend /api /routes_query.py
shankarsain's picture
Clean up repository and update codebase for vidyabot-build-small
a2398a7
Raw
History Blame Contribute Delete
11.3 kB
"""
Query Routes
POST /api/query - Answer student question with context pruning and cost tracking
"""
import logging
import time
import hashlib
from typing import Dict, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend.database import get_db_connection
from backend.retrieval.context_pruner import ContextPruner
from backend.llm.prompt_builder import PromptBuilder
from backend.cache.semantic_cache import get_cache
from backend.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["query"])
# Supported languages for translation
SUPPORTED_LANGUAGES = ["english", "hindi", "kannada", "telugu", "tamil", "marathi", "bengali"]
def get_llm_client():
"""Factory: returns OllamaClient or ClaudeClient based on config."""
if settings.LLM_BACKEND == "ollama":
from backend.llm.ollama_client import OllamaClient
return OllamaClient()
else:
from backend.llm.claude_client import ClaudeClient
return ClaudeClient()
def translate_text(text: str, target_lang: str, source_lang: str = "english") -> Optional[str]:
"""
Translate text using deep-translator library.
Returns None on failure (graceful degradation).
"""
# Only skip translation if source and target are the same
if target_lang == source_lang:
return text
try:
from deep_translator import GoogleTranslator
# Map language codes
lang_map = {
"hindi": "hi",
"kannada": "kn",
"telugu": "te",
"tamil": "ta",
"marathi": "mr",
"bengali": "bn",
"english": "en"
}
source_code = lang_map.get(source_lang, "en")
target_code = lang_map.get(target_lang, "en")
# Always attempt translation when source != target
translator = GoogleTranslator(source_language=source_code, target_language=target_code)
translated = translator.translate(text)
logger.info(f"Successfully translated from {source_lang} to {target_lang}")
return translated
except Exception as e:
logger.warning(f"Translation from {source_lang} to {target_lang} failed: {e}")
return None
class QueryRequest(BaseModel):
"""Student query request model."""
question: str
textbook_id: int
language: str = "english"
mode: str = "answer" # answer | socratic | quiz
class QueryResponse(BaseModel):
"""Query response model with detailed metrics."""
answer: str
language: str
tokens_used: int
baseline_tokens: int
tokens_saved: int
cost_usd: float
cost_saved_usd: float
cache_hit: bool
pruning_ratio: float
time_ms: float
pruning_stages: Dict
source_pages: str
@router.post("/query", response_model=QueryResponse)
async def answer_question(req: QueryRequest) -> QueryResponse:
"""
Answer a student question using context pruning and Claude Haiku.
Pipeline:
1. If language != english, translate question to english
2. Check semantic cache → if hit, return cached answer (0 tokens)
3. Run 3-stage context pruner
4. Build prompt within token budget
5. Call Claude Haiku
6. If language != english, translate answer back
7. Log cost savings
8. Cache result for future queries
Args:
req: QueryRequest with question, textbook_id, language, mode
Returns:
QueryResponse with answer and detailed metrics
"""
start_time = time.time()
try:
# Validate request
if not req.question or len(req.question.strip()) == 0:
raise HTTPException(status_code=400, detail="Question cannot be empty")
if req.textbook_id <= 0:
raise HTTPException(status_code=400, detail="Invalid textbook_id")
logger.info(f"Query: {req.question[:50]} | Textbook: {req.textbook_id} | Lang: {req.language}")
# Translate question if needed (graceful fallback if translation fails)
query_text = req.question
translation_note = ""
if req.language != "english":
translated = translate_text(req.question, "english", req.language)
if translated:
query_text = translated
logger.info(f"Translated question from {req.language} to English")
else:
# Graceful degradation: answer in English with a note
translation_note = f"[Note: Answering in English — {req.language} support coming soon]\n\n"
logger.warning(f"Translation from {req.language} failed, proceeding with English question")
# ========== STAGE 0: Check Semantic Cache ==========
cache = get_cache()
cached_result = cache.check_cache(query_text, req.textbook_id)
if cached_result:
# Cache hit - return immediately
elapsed_ms = (time.time() - start_time) * 1000
answer = cached_result["answer"]
# Add translation note if applicable
if translation_note and req.language != "english":
answer = translation_note + answer
# Attempt to translate back to original language if we have resources
if req.language != "english":
translated_answer = translate_text(answer, req.language, "english")
if translated_answer:
answer = translated_answer
logger.info(f"Translated answer back to {req.language}")
# If translation fails, return English answer with note
return QueryResponse(
answer=answer,
language=req.language,
tokens_used=0,
baseline_tokens=settings.BASELINE_TOKENS,
tokens_saved=settings.BASELINE_TOKENS,
cost_usd=0.0,
cost_saved_usd=cached_result.get("cost_saved_usd", 0.0),
cache_hit=True,
pruning_ratio=cached_result.get("pruning_ratio", 0.0),
time_ms=elapsed_ms,
pruning_stages={
"type": "cache_hit",
"cache_similarity": cached_result.get("similarity", 0.0)
},
source_pages=cached_result.get("source_pages", "")
)
# ========== STAGE 1-3: Context Pruning ==========
pruner = ContextPruner()
pruner_start = time.time()
pruning_result = pruner.prune(query_text, req.textbook_id)
pruning_ms = (time.time() - pruner_start) * 1000
logger.info(
f"Pruning: {len(pruning_result.chunks)} chunks, "
f"{pruning_result.total_tokens} tokens "
f"({pruning_result.pruning_ratio*100:.1f}% reduction)"
)
# ========== Build Prompts ==========
prompt_builder = PromptBuilder(
grade=8, # Default grade - could extract from teacher profile
language=req.language
)
system_prompt = prompt_builder.build_system_prompt()
user_prompt = prompt_builder.build_user_prompt(
query_text,
pruning_result.chunks
)
# ========== Call LLM (Ollama or Claude) ==========
llm_client = get_llm_client()
llm_start = time.time()
llm_response = llm_client.ask(
system_prompt=system_prompt,
user_prompt=user_prompt,
max_tokens=256
)
llm_ms = (time.time() - llm_start) * 1000
answer = llm_response.answer
# Add translation note if query language was non-English
if translation_note:
answer = translation_note + answer
# Translate answer back if needed (graceful fallback)
if req.language != "english":
translated_answer = translate_text(answer, req.language, "english")
if translated_answer:
answer = translated_answer
logger.info(f"Translated answer to {req.language}")
else:
# Graceful degradation: return English answer with note
logger.warning(f"Answer translation to {req.language} failed, returning English")
# Already has translation_note prepended
# ========== Log Cost & Save ==========
conn = get_db_connection()
cursor = conn.cursor()
# Calculate costs
baseline_cost = (settings.BASELINE_TOKENS / 1_000_000) * settings.HAIKU_INPUT_COST_PER_1M
actual_cost = llm_response.cost_usd
cost_saved = baseline_cost - actual_cost
# Extract source pages
source_pages = ",".join(
str(c.page_number) for c in pruning_result.chunks
if c.page_number
)
# Log cost
cursor.execute("""
INSERT INTO cost_log
(baseline_tokens, actual_tokens_used, tokens_saved, cost_usd,
cost_saved_usd, cache_hit, model_used)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
settings.BASELINE_TOKENS,
llm_response.input_tokens + llm_response.output_tokens,
pruning_result.tokens_saved,
actual_cost,
cost_saved,
False,
llm_response.model
))
conn.commit()
logger.info(
f"✅ Query complete. Cost: ${actual_cost:.6f}, "
f"Saved: ${cost_saved:.6f} ({(cost_saved/baseline_cost)*100:.1f}%)"
)
# ========== Cache Result ==========
cache.store_in_cache(
query=query_text,
answer=answer,
context_tokens_used=llm_response.input_tokens,
textbook_id=req.textbook_id,
model_used=llm_response.model,
pruning_ratio=pruning_result.pruning_ratio,
source_pages=source_pages
)
# ========== Return Response ==========
elapsed_ms = (time.time() - start_time) * 1000
return QueryResponse(
answer=answer,
language=req.language,
tokens_used=llm_response.input_tokens + llm_response.output_tokens,
baseline_tokens=settings.BASELINE_TOKENS,
tokens_saved=pruning_result.tokens_saved,
cost_usd=actual_cost,
cost_saved_usd=cost_saved,
cache_hit=False,
pruning_ratio=pruning_result.pruning_ratio,
time_ms=elapsed_ms,
pruning_stages={
"bm25_candidates": settings.BM25_TOP_K,
"semantic_candidates": settings.SEMANTIC_TOP_K,
"final_chunks": len(pruning_result.chunks),
"timings_ms": pruning_result.stage_timings
},
source_pages=source_pages
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error processing query: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))