Intelex / backend /api /query.py
yakub
feat: implement real-time source ingestion progress, chitchat intent classifier, and empty state fallback
fd1e711
Raw
History Blame Contribute Delete
26.1 kB
# backend/api/query.py β€” FIXED: Multi-source, context, and history
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from backend.rag.retriever import retrieve
from backend.rag.generator import generate_answer, generate_answer_stream, _build_citations
from backend.rag.query_classifier import classify_query, QueryAnalysis, extract_source_filter
from backend.rag.multi_retriever import (
MultiSourceResult, multi_retrieve, retrieve_multi_selected, retrieve_single_source
)
from backend.rag.multi_generator import generate_multi_answer
from backend.rag.image_rag import enrich_query_with_image_context
from backend.database.connection import get_connection
import uuid
import json
router = APIRouter()
class ChatMessageModel(BaseModel):
role : str
content: str
class QueryRequest(BaseModel):
question : str
source_ids : list[str] | None = None
history : list[ChatMessageModel] | None = None
mode : str | None = None
conversation_id : str | None = None
image_id : str | None = None
include_images : bool = False
llm_provider : str | None = "groq"
is_legal_mode : bool = False
legal_filter : str | None = None # "statute", "judgment", or None
agentic_mode : bool = False # ← Deep Research: 3-stage Plannerβ†’Searcherβ†’Validator
def _history_to_dicts(history: list[ChatMessageModel] | None) -> list[dict] | None:
if not history:
return None
return [{"role": m.role, "content": m.content} for m in history]
def _contextualize_query(question: str, history: list[dict] | None) -> str:
"""
Rewrite the user's question by injecting the last assistant response
as context. This resolves pronouns ("they", "it", "those", "that")
so the classifier and reranker see a self-contained question.
Example:
history[-1] = {role: assistant, content: "Cipher techniques include...
digital signatures are used for..."}
question = "how are they different from message digests?"
β†’ contextualized = "[Context: Cipher techniques include...
digital signatures are used for...]
how are they different from message digests?"
The LLM receives the original `question` for display purposes.
The `contextualized` version is only used for retrieval + classification.
"""
if not history or not question.strip():
return question
# Only do this if query contains common pronouns / relative references
import re
pronoun_pattern = re.compile(
r'\b(they|them|their|it|its|this|that|those|these|he|she|his|her|the same|the above)\b',
re.IGNORECASE
)
if not pronoun_pattern.search(question):
return question
# Find the last assistant message
last_assistant = None
for msg in reversed(history):
if msg.get("role") == "assistant" and msg.get("content"):
last_assistant = msg["content"]
break
if not last_assistant:
return question
# Take first 400 chars of last answer as context prefix (keeps prompt short)
context_snippet = last_assistant[:400].strip()
if len(last_assistant) > 400:
context_snippet += "..."
contextualized = f"[Previous context: {context_snippet}]\n{question}"
print(f"[Query] Contextualized query for pronoun resolution ({len(question)} β†’ {len(contextualized)} chars)")
return contextualized
def _safe_classify(question: str) -> QueryAnalysis:
"""Always returns a valid QueryAnalysis, never raises."""
try:
return classify_query(question)
except Exception as e:
print(f"[Query] Classifier failed, using default: {e}")
return QueryAnalysis(
intent="single_source", source_types=["any"], topics=[],
ipc_sections=[], time_filter=None, language_hint="en",
requires_compare=False, requires_summary=False, source_names=[]
)
def _do_retrieve(question: str, req_source_ids: list[str] | None, analysis: QueryAnalysis) -> MultiSourceResult:
"""
THE RETRIEVAL ROUTER.
Priority:
1. User explicitly selected sources β†’ use retrieve_multi_selected (CORE FEATURE)
- 1 source β†’ single_source path
- 2+ sources β†’ multi_selected path (synthesis intent forced)
2. Otherwise β†’ let classifier intent decide
"""
if req_source_ids and len(req_source_ids) > 0:
if len(req_source_ids) == 1:
# Single explicit source
return retrieve_single_source(question, source_ids=req_source_ids)
else:
# MULTI-SOURCE CONSOLIDATION β€” the core product feature
# Force synthesis intent regardless of what the classifier said
return retrieve_multi_selected(question, source_ids=req_source_ids)
else:
# No manual selection β€” let classifier decide
return multi_retrieve(question, analysis)
def _ensure_conversation(cursor, conv_id: str | None, question: str, conv_type: str = "general") -> str:
if conv_id:
return conv_id
new_id = str(uuid.uuid4())
title = question[:60] + ("..." if len(question) > 60 else "")
cursor.execute(
"INSERT INTO conversations (id, title, conv_type) VALUES (%s, %s, %s)",
(new_id, title, conv_type)
)
return new_id
def _save_to_db(chat_id: str, conv_id: str, question: str, answer: str, source_ids_used: list[str]) -> None:
try:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO chat_history (id, question, answer, sources_used, conversation_id) VALUES (%s, %s, %s, %s, %s)",
(chat_id, question, answer, json.dumps(source_ids_used), conv_id)
)
cursor.execute("UPDATE conversations SET updated_at = NOW() WHERE id = %s", (conv_id,))
conn.commit()
print(f"[Query] Saved chat {chat_id[:8]} to conv {conv_id[:8]}")
except Exception as e:
print(f"[Query] DB save warning: {e}")
def _pre_create_conv(conv_id: str | None, question: str, conv_type: str = "general") -> str:
"""Pre-create a conversation row before streaming starts so meta event can carry real ID."""
if conv_id:
return conv_id
try:
with get_connection() as conn:
cursor = conn.cursor()
new_id = _ensure_conversation(cursor, None, question, conv_type)
conn.commit()
return new_id
except Exception as e:
print(f"[Query] Pre-create conv warning: {e}")
return str(uuid.uuid4())
def _format_chunks_out(chunks: list) -> list[dict]:
formatted = []
for i, c in enumerate(chunks):
# Build timestamped URL for YouTube
final_url = c.url_ref
if c.source_type == "youtube" and c.url_ref and c.timestamp_s is not None:
sep = "&" if "?" in c.url_ref else "?"
final_url = f"{c.url_ref}{sep}t={c.timestamp_s}s"
# Human-readable timestamp
time_str = None
if c.timestamp_s is not None:
time_str = f"{c.timestamp_s // 60}:{c.timestamp_s % 60:02d}"
formatted.append({
"id": c.chunk_id,
"sourceId": c.source_id,
"sourceName": c.source_title,
"sourceType": c.source_type,
"text": c.chunk_text,
"similarityScore": round(c.score, 4),
"language": c.language or "en",
"metadata": {
"page": c.page_number,
"timestamp": time_str,
"url": final_url
}
})
return formatted
def _format_citations_out(citations: list) -> list[dict]:
return [
{
"sourceId": c.source_id,
"sourceType": c.source_type,
"sourceTitle": c.source_title,
"reference": c.reference,
"snippet": c.snippet,
"score": round(c.score, 4),
}
for c in citations
]
# ── /query β€” standard chitchat / conversational fallback helper ────────────────
def build_chat_prompt(question: str, history: list[dict] | None = None) -> list[dict]:
"""
Build the messages prompt for a general conversational turn.
Avoids retrieval entirely.
"""
messages = [
{
"role": "system",
"content": (
"You are InteleX, a premium Senior Staff AI Research Assistant. "
"The user is engaging in general chitchat or casual conversation, or asking about your capabilities. "
"Respond in a warm, professional, and friendly manner. "
"Briefly mention that you are equipped to perform Multi-Source Agentic RAG "
"across PDFs, images, websites, and YouTube video transcripts, and you can "
"do comparative and synthetic analyses. Keep your answer engaging, helpful, and concise."
)
}
]
if history:
for m in history:
messages.append({"role": m["role"], "content": m["content"]})
messages.append({"role": "user", "content": question})
return messages
# ── /query β€” standard non-streaming ──────────────────────────────────────────
@router.post("/query")
def query(req: QueryRequest):
if not req.question.strip():
raise HTTPException(status_code=400, detail="Question cannot be empty.")
history = _history_to_dicts(req.history)
enriched_question, image_context_block = enrich_query_with_image_context(
req.question, image_id=req.image_id, include_recent=req.include_images
)
# Contextualize: expand pronouns using the last assistant turn
retrieval_question = _contextualize_query(enriched_question, history)
analysis = _safe_classify(retrieval_question)
# Graceful fallback: check if there are no sources at all in the DB
has_sources = True
try:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sources")
source_count = cursor.fetchone()[0]
if source_count == 0:
has_sources = False
except Exception as e:
print(f"[Query] Error checking sources count: {e}")
# ROUTING DECISION: Conversational CHAT Mode
if analysis.route == "chat" or not has_sources:
chat_id = str(uuid.uuid4())
conv_id = req.conversation_id or ""
if not conv_id:
try:
with get_connection() as conn:
cursor = conn.cursor()
conv_id = _ensure_conversation(cursor, None, req.question, "general")
conn.commit()
except Exception as e:
print(f"[Query] Conv create warning: {e}")
conv_id = str(uuid.uuid4())
if not has_sources and analysis.route != "chat":
answer = (
"It looks like no knowledge sources have been added to my database yet! "
"Please upload a PDF document, add a website URL, or ingest a YouTube video in the sidebar "
"or tabs first, so that I can analyze and answer questions based on your specific documents."
)
else:
messages = build_chat_prompt(req.question, history)
try:
from backend.rag.generator import _get_groq_client, GROQ_MODEL, GROQ_TIMEOUT
client = _get_groq_client()
resp = client.chat.completions.create(
model=GROQ_MODEL,
messages=messages,
timeout=GROQ_TIMEOUT,
)
answer = resp.choices[0].message.content.strip()
except Exception as e:
answer = f"Hello! I am InteleX, your Staff AI Research Assistant. I'm ready to assist, but I encountered an error generating a response: {e}"
_save_to_db(chat_id, conv_id, req.question, answer, [])
return {
"chatId": chat_id,
"conversationId": conv_id,
"answer": answer,
"citations": [],
"retrievedChunks": [],
"query_intent": "chat",
"imageContextUsed": False,
}
# ROUTING DECISION: RAG Mode (Retrieve & Generate)
try:
multi_result = _do_retrieve(retrieval_question, req.source_ids, analysis)
chunks = multi_result.all_chunks
# ── Mandatory Image Consideration ─────────────────────────────────────
if req.image_id:
from backend.rag.retriever import fetch_image_chunk
img_chunk = fetch_image_chunk(req.image_id)
if img_chunk:
chunks = [img_chunk] + chunks
multi_result.all_chunks = chunks
if img_chunk.source_title not in multi_result.source_groups:
multi_result.source_groups[img_chunk.source_title] = [img_chunk]
multi_result.source_count = len(multi_result.source_groups)
print(f"[Query] Injected image chunk and group for {req.image_id}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Retrieval error: {str(e)}")
if not chunks:
return {
"chatId": str(uuid.uuid4()), "conversationId": req.conversation_id,
"answer": "No relevant information found in the selected sources. Please check that documents have been uploaded and try a different question.",
"citations": [], "retrievedChunks": [], "query_intent": analysis.intent, "imageContextUsed": False
}
augmented_history = list(history) if history else []
if image_context_block:
augmented_history = [{"role": "system", "content": image_context_block}] + augmented_history
try:
is_legal = req.is_legal_mode or (req.llm_provider == "huggingface")
result = generate_answer(
req.question,
multi_result,
history=augmented_history,
image_context=image_context_block,
provider_name=req.llm_provider,
is_legal=is_legal
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Generation error: {str(e)}")
chat_id = str(uuid.uuid4())
source_ids_used = list({c.source_id for c in chunks})
conv_id = req.conversation_id or ""
if not conv_id:
try:
with get_connection() as conn:
cursor = conn.cursor()
conv_type = "legal" if is_legal else "general"
conv_id = _ensure_conversation(cursor, None, req.question, conv_type)
conn.commit()
except Exception as e:
print(f"[Query] Conv create warning: {e}")
_save_to_db(chat_id, conv_id, req.question, result.answer, source_ids_used)
return {
"chatId": chat_id, "conversationId": conv_id,
"answer": result.answer,
"citations": _format_citations_out(result.citations),
"retrievedChunks": _format_chunks_out(result.chunks),
"query_intent": analysis.intent,
"imageContextUsed": bool(image_context_block),
}
# ── /query-stream β€” SSE streaming (PRIMARY PATH) ─────────────────────────────
@router.post("/query-stream")
def query_stream(req: QueryRequest):
if not req.question.strip():
raise HTTPException(status_code=400, detail="Question cannot be empty.")
history = _history_to_dicts(req.history)
enriched_question, image_context_block = enrich_query_with_image_context(
req.question, image_id=req.image_id, include_recent=req.include_images
)
# Contextualize: expand pronouns using the last assistant turn
retrieval_question = _contextualize_query(enriched_question, history)
# 1. Classify (on the contextualized question for better intent detection)
analysis = _safe_classify(retrieval_question)
# Graceful fallback: check if there are no sources at all in the DB
has_sources = True
try:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sources")
source_count = cursor.fetchone()[0]
if source_count == 0:
has_sources = False
except Exception as e:
print(f"[QueryStream] Error checking sources count: {e}")
# ROUTING DECISION: Conversational CHAT Mode
if analysis.route == "chat" or not has_sources:
chat_id = str(uuid.uuid4())
is_legal = req.is_legal_mode or (req.llm_provider == "huggingface")
conv_type = "legal" if is_legal else "general"
conv_id = _pre_create_conv(req.conversation_id, req.question, conv_type)
def chat_event_stream():
yield f"data: {json.dumps({'type': 'meta', 'chatId': chat_id, 'conversationId': conv_id, 'citations': [], 'retrievedChunks': [], 'sourceCount': 0, 'activeProvider': req.llm_provider or 'groq'})}\n\n"
if not has_sources and analysis.route != "chat":
fallback_msg = (
"It looks like no knowledge sources have been added to my database yet! "
"Please upload a PDF document, add a website URL, or ingest a YouTube video in the sidebar "
"or tabs first, so that I can analyze and answer questions based on your specific documents."
)
yield f"data: {json.dumps({'type': 'token', 'content': fallback_msg})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
_save_to_db(chat_id, conv_id, req.question, fallback_msg, [])
return
messages = build_chat_prompt(req.question, history)
collected = []
try:
from backend.rag.generator import _get_groq_client, GROQ_MODEL, GROQ_TIMEOUT
client = _get_groq_client()
stream = client.chat.completions.create(
model=GROQ_MODEL,
messages=messages,
stream=True,
timeout=GROQ_TIMEOUT,
)
for chunk_response in stream:
token = chunk_response.choices[0].delta.content
if token is not None:
collected.append(token)
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return
yield f"data: {json.dumps({'type': 'done'})}\n\n"
full_answer = "".join(collected).strip()
_save_to_db(chat_id, conv_id, req.question, full_answer, [])
return StreamingResponse(
chat_event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
# ROUTING DECISION: RAG Mode (Retrieve & Generate)
try:
multi_result = _do_retrieve(retrieval_question, req.source_ids, analysis)
chunks = multi_result.all_chunks
# ── Mandatory Image Consideration ─────────────────────────────────────
if req.image_id:
from backend.rag.retriever import fetch_image_chunk
img_chunk = fetch_image_chunk(req.image_id)
if img_chunk:
chunks = [img_chunk] + chunks
if img_chunk.source_title not in multi_result.source_groups:
multi_result.source_groups[img_chunk.source_title] = [img_chunk]
multi_result.source_count = len(multi_result.source_groups)
print(f"[QueryStream] Injected image chunk and group for {req.image_id}")
print(f"[QueryStream] Retrieved {len(chunks)} chunks from {multi_result.source_count} sources")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Retrieval error: {str(e)}")
chat_id = str(uuid.uuid4())
is_legal = req.is_legal_mode or (req.llm_provider == "huggingface")
conv_type = "legal" if is_legal else "general"
conv_id = _pre_create_conv(req.conversation_id, req.question, conv_type)
citations_out = _format_citations_out(_build_citations(chunks))
chunks_out = _format_chunks_out(chunks)
augmented_history = list(history) if history else []
if image_context_block:
augmented_history = [{"role": "system", "content": image_context_block}] + augmented_history
def event_stream():
nonlocal multi_result, chunks
if req.agentic_mode:
try:
from backend.rag.agent_workflow import run_agentic_workflow
def _retriever_fn(q: str, sids):
from backend.api.query import _safe_classify, _do_retrieve
analysis = _safe_classify(q)
return _do_retrieve(q, sids or req.source_ids, analysis)
yield f"data: {json.dumps({'type': 'agent_status', 'stage': 0, 'message': 'πŸš€ Deep Research Mode activated β€” starting multi-stage analysis...'})}\n\n"
is_legal_flag = req.is_legal_mode or (req.llm_provider == "huggingface")
multi_result, status_log = run_agentic_workflow(
question=req.question,
retriever_fn=_retriever_fn,
source_ids=req.source_ids,
is_legal=is_legal_flag,
)
chunks = multi_result.all_chunks
for i, msg in enumerate(status_log):
yield f"data: {json.dumps({'type': 'agent_status', 'stage': i + 1, 'message': msg})}\n\n"
except Exception as e:
print(f"[QueryStream] Agentic workflow error: {e} β€” falling back to standard retrieval")
yield f"data: {json.dumps({'type': 'agent_status', 'stage': 0, 'message': f'⚠️ Deep research unavailable ({str(e)[:60]}), using standard retrieval'})}\n\n"
citations_final = _format_citations_out(_build_citations(chunks))
chunks_final = _format_chunks_out(chunks)
yield f"data: {json.dumps({'type': 'meta', 'chatId': chat_id, 'conversationId': conv_id, 'citations': citations_final, 'retrievedChunks': chunks_final, 'sourceCount': multi_result.source_count, 'activeProvider': req.llm_provider or 'groq'})}\n\n"
if not chunks:
yield f"data: {json.dumps({'type': 'token', 'content': 'No relevant information found in the selected sources. Please check that documents have been uploaded and try a different question.'})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return
collected = []
try:
is_legal = req.is_legal_mode or (req.llm_provider == "huggingface")
for token in generate_answer_stream(
req.question,
multi_result,
history=augmented_history,
image_context=image_context_block,
provider_name=req.llm_provider,
is_legal=is_legal
):
collected.append(token)
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return
yield f"data: {json.dumps({'type': 'done'})}\n\n"
full_answer = "".join(collected).strip()
source_ids_used = list({c.source_id for c in chunks})
_save_to_db(chat_id, conv_id, req.question, full_answer, source_ids_used)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
# ── /query/debug β€” Diagnostic ─────────────────────────────────────────────────
@router.get("/query/debug")
def query_debug(question: str = "test query"):
"""GET /query/debug?question=... β€” trace FAISS + MySQL pipeline."""
from backend.ingestion.embedder import embed_query
from backend.vectorstore import search_vectors, get_stats
import os
results = {"question": question, "faiss_stats": get_stats(), "steps": []}
try:
results["steps"].append("1. Embedding query...")
vec = embed_query(question)
results["steps"].append("2. Searching FAISS...")
raw_hits = search_vectors(vec, top_k=5)
results["faiss_hits"] = raw_hits
if not raw_hits:
results["steps"].append("WARNING: FAISS returned 0 hits.")
return results
results["steps"].append(f"3. Querying MySQL for {len(raw_hits)} IDs...")
chunk_ids = [h["chunk_id"] for h in raw_hits]
placeholders = ", ".join(["%s"] * len(chunk_ids))
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(f"SELECT id, source_id, chunk_text FROM chunks WHERE id IN ({placeholders})", chunk_ids)
db_rows = cursor.fetchall()
for r in db_rows:
r["snippet"] = (r.get("chunk_text") or "")[:100] + "..."
r.pop("chunk_text", None)
results["db_rows"] = db_rows
results["steps"].append(f"Found {len(db_rows)} matching rows in DB.")
except Exception as e:
results["error"] = str(e)
return results