""" Core RAG query pipeline: 1. Resolve standalone question (multi-turn) 2. Rewrite query for better retrieval 3. Retrieve + rerank 4. Build prompt with context 5. Generate answer (sync or streaming) 6. Return answer + sources """ import hashlib import json import logging import re import time from typing import AsyncIterator, Optional from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler from .config import get_settings from .prompt import SYSTEM_PROMPT, QUERY_REWRITE_PROMPT, MULTI_DOC_SYSTEM_PROMPT from .models import QueryRequest, QueryResponse, SourceDocument from .retriever import retrieve, detect_query_scope, multi_collection_retrieve from .vector_store import resolve_embedding_mode_for_collections from .memory import resolve_standalone_question,trim_history_to_budget, build_lc_messages from .guardrails import check_query, check_context, redact_pii from .cache import ( CACHE_EMBEDDING_MODE, get_exact, set_exact, get_semantic, set_semantic, ) from .embeddings import embed_query logger = logging.getLogger(__name__) settings = get_settings() # LLM Singleton def _build_llm(streaming: bool = False) -> ChatOpenAI: return ChatOpenAI( model=settings.chat_model, temperature=settings.llm_temperature, max_tokens=settings.llm_max_tokens, openai_api_key = settings.openai_api_key, streaming=streaming, callbacks=[StreamingStdOutCallbackHandler()] if streaming else None, ) _llm = _build_llm() _SECTION_REF_RE = re.compile(r"\b\d+\.\d+\b") _SECTION_HINT_RE = re.compile(r"\b(section|clause|exclusion|code|excl)\b", re.IGNORECASE) _RAG_DECISION_PROMPT = ( "You are a routing assistant for a retrieval-augmented chat system.\n" "Decide if the user's question can be answered using ONLY the prior chat history.\n" "If the history provides enough info to answer confidently, respond with JSON:\n" '{"use_rag": false, "answer": "..."}\n' "If not, respond with JSON:\n" '{"use_rag": true, "answer": ""}\n' "Rules: Use only chat history, do not guess. If unsure, set use_rag true. Output JSON only." ) def _should_preserve_exact_reference(query: str) -> bool: """ Preserve exact retrieval query when user asks about numbered clauses/sections, e.g. "7.14 exclusion". Rewriting often dilutes these anchors. """ return bool(_SECTION_REF_RE.search(query) and _SECTION_HINT_RE.search(query)) def _cache_collection_key(collections: list[str]) -> str: raw = "|".join(sorted(collections)) return hashlib.sha1(raw.encode()).hexdigest()[:16] def _fmt_param(value: Optional[float]) -> str: if value is None: return "-" return f"{value:.3f}" def _cache_params_key_v2( mode: str, top_k: Optional[int], top_k_retrieval: Optional[int], mmr_lambda: Optional[float], bm25_weight: Optional[float], vector_weight: Optional[float], ) -> str: k_final = top_k if top_k is not None else settings.top_k_rerank k_retrieve = top_k_retrieval if top_k_retrieval is not None else settings.top_k_retrieval return ( f"{mode}:{k_final}:{k_retrieve}:" f"{_fmt_param(mmr_lambda)}:{_fmt_param(bm25_weight)}:{_fmt_param(vector_weight)}" ) async def _decide_rag_or_answer( question: str, history: list[dict], llm: ChatOpenAI, ) -> tuple[bool, Optional[str]]: if not history: return True, None messages = build_lc_messages(history, _RAG_DECISION_PROMPT) messages.append(HumanMessage(content=f"User question: {question}")) try: response = await llm.ainvoke(messages) raw = response.content.strip() data = None try: data = json.loads(raw) except Exception: match = re.search(r"\{.*\}", raw, re.DOTALL) if match: data = json.loads(match.group(0)) if not isinstance(data, dict): return True, None use_rag = bool(data.get("use_rag", True)) answer = data.get("answer") if not use_rag else None if not use_rag and isinstance(answer, str) and answer.strip(): return False, answer.strip() return True, None except Exception: logger.warning("RAG routing decision failed; defaulting to retrieval", exc_info=True) return True, None # Query rewriting async def rewrite_query(query: str) -> str: """ HyDE-lite: rewrite the query to be more retrieval-friendly. For full HyDE, generate a hypothetical answer and embed that instead """ prompt = QUERY_REWRITE_PROMPT.format(query=query) response = await _llm.ainvoke([HumanMessage(content=prompt)]) rewritten = response.content.strip() logger.debug(f"Rewritten query: '{rewritten}'") return rewritten # HyDE (Hypothetical Document Embeddings) async def hyde_query_expansion(query: str) -> str: """ Generate a hypothetical answer to the question, then embed that answer for retrieval. Often finds more relevant chunks than embedding the question alone """ prompt = ( f"Write a short factual paragraph that would answer the following question.\n" f"Question: {query}" f"Answer:" ) response = await _llm.ainvoke([HumanMessage(content=prompt)]) return response.content.strip() # Context builder def build_context_block(docs_with_scores: list) -> tuple[str, list[SourceDocument]]: """ Build the prompt block and source list. Wraps in XML tags to help the model distinguish context from instructions. """ context_parts: list[str] = [] sources: list[SourceDocument] = [] for doc,score in docs_with_scores: doc_id = doc.metadata.get("doc_id","unknown") suspicious = check_context(doc.page_content) content = doc.page_content if suspicious: content = redact_pii(content) # sanitize if suspicious # Build human-readable attributes for the context tag source_id = doc.metadata.get("source_id", "unknown") source_label = source_id.replace("\\", "/").split("/")[-1] if source_id != "unknown" else "unknown" raw_page = doc.metadata.get("page") page_attr = f' page="{int(raw_page) + 1}"' if raw_page is not None else "" context_parts.append( f'\n{content}\n' ) sources.append(SourceDocument( doc_id=doc_id, content=content[:300]+"..." if len(content) > 300 else content, metadata=doc.metadata, relevance_score=round(score,4), )) context_str = "\n" + "\n\n".join(context_parts) + "\n" return context_str,sources def build_grouped_context_block( docs_with_scores: list, ) -> tuple[str, list[SourceDocument]]: """ Groups retrieved chunks by source document for multi-doc queries. Produces clearly-attributed blocks so the LLM can reason about what each document says independently. Falls back to flat build_context_block when all chunks share one source. """ groups: dict[str, list] = {} for doc, score in docs_with_scores: source_id = doc.metadata.get("source_id", "unknown") filename = source_id.replace("\\", "/").split("/")[-1] groups.setdefault(filename, []).append((doc, score)) if len(groups) <= 1: return build_context_block(docs_with_scores) context_parts: list[str] = [] sources: list[SourceDocument] = [] for filename, items in groups.items(): chunk_xmls: list[str] = [] for doc, score in items: suspicious = check_context(doc.page_content) content = redact_pii(doc.page_content) if suspicious else doc.page_content raw_page = doc.metadata.get("page") page_attr = f' page="{int(raw_page) + 1}"' if raw_page is not None else "" chunk_xmls.append( f' \n{content}\n ' ) doc_id = doc.metadata.get("doc_id", "unknown") sources.append(SourceDocument( doc_id=doc_id, content=content[:300] + "..." if len(content) > 300 else content, metadata=doc.metadata, relevance_score=round(float(score), 4), )) context_parts.append( f'\n' + "\n".join(chunk_xmls) + "\n" ) context_str = "\n" + "\n\n".join(context_parts) + "\n" return context_str, sources #Main Query Pipeline async def query( request: QueryRequest, use_hyde: bool = False, ) -> QueryResponse: start = time.monotonic() collections = request.doc_collections or [request.collection_name] embedding_mode = resolve_embedding_mode_for_collections(collections, request.embedding_mode) mode_val = request.retrieval_mode.value if hasattr(request.retrieval_mode, "value") else str(request.retrieval_mode) cache_allowed = settings.cache_enabled cache_collection_key = _cache_collection_key(collections) cache_params_key = _cache_params_key_v2( mode_val, request.top_k, request.top_k_retrieval, request.mmr_lambda, request.bm25_weight, request.vector_weight, ) cache_query_vec = None # 1. Input guardrail guard = check_query(request.query) if not guard.allowed: return QueryResponse( answer=f"Request blocked: {guard.reason}", sources = [], session_id=request.session_id, latency_ms=0 ) # 2. Exact cache check if cache_allowed: cached = get_exact(request.query, cache_collection_key, cache_params_key) if cached: logger.info(f"Exact cache hit for query: '{request.query}'") cached["cached"] = True cached["latency_ms"] = round((time.monotonic()-start)*1000,2) return QueryResponse(**cached) # 3. Embed query for semantic cache + later retrieval if cache_allowed: cache_query_vec = await embed_query(request.query, CACHE_EMBEDDING_MODE) semantic_hit = get_semantic(cache_query_vec, cache_collection_key, cache_params_key) if semantic_hit: logger.info(f"Semantic cache hit for query: '{request.query}'") semantic_hit["cached"] = True semantic_hit["latency_ms"] = round((time.monotonic()-start)*1000,2) return QueryResponse(**semantic_hit) # 4. Resolve standalone question (multi-turn) history = [h.model_dump() for h in request.history] trimmed_history = trim_history_to_budget(history) standalone = await resolve_standalone_question(request.query, trimmed_history, _llm) # 5. Query rewrite / HyDE if _should_preserve_exact_reference(standalone): retrieval_query = standalone logger.info("Skipping query rewrite to preserve section/clause reference: '%s'", standalone) elif use_hyde: retrieval_query = await hyde_query_expansion(standalone) else: retrieval_query = await rewrite_query(standalone) # 5.5 Decide if retrieval is needed based on chat history use_rag, history_answer = await _decide_rag_or_answer(standalone, trimmed_history, _llm) if not use_rag and history_answer: latency_ms = round((time.monotonic() - start) * 1000, 2) return QueryResponse( answer=history_answer, sources=[], session_id=request.session_id, rewritten_query=retrieval_query if retrieval_query != request.query else None, cached=False, latency_ms=latency_ms, ) # 6. Retrieve — multi-doc aware if len(collections) > 1: scoped = detect_query_scope(retrieval_query, collections) k_per = max(3, (request.top_k or settings.top_k_rerank) // len(scoped)) docs_with_scores = await multi_collection_retrieve( query=retrieval_query, collections=scoped, mode=request.retrieval_mode.value if hasattr(request.retrieval_mode, "value") else str(request.retrieval_mode), k_per_collection=k_per, top_k_retrieval=request.top_k_retrieval, mmr_lambda=request.mmr_lambda, bm25_weight=request.bm25_weight, vector_weight=request.vector_weight, use_reranker=True, expand_context=True, ) is_multi = len(scoped) > 1 else: docs_with_scores = await retrieve( query=retrieval_query, collection=collections[0], mode=request.retrieval_mode, top_k=request.top_k, top_k_retrieval=request.top_k_retrieval, mmr_lambda=request.mmr_lambda, bm25_weight=request.bm25_weight, vector_weight=request.vector_weight, use_reranker=True, expand_context=True, ) is_multi = False if not docs_with_scores: latency_ms = round((time.monotonic() - start) * 1000, 2) clarify = ( "Can you clarify your question with a bit more detail " "(topic, document name, section, or timeframe)?" ) return QueryResponse( answer=clarify, sources=[], session_id=request.session_id, rewritten_query=retrieval_query if retrieval_query != request.query else None, cached=False, latency_ms=latency_ms, ) # 7. Build Prompt — grouped for multi-doc, flat for single-doc context_str, sources = ( build_grouped_context_block(docs_with_scores) if is_multi else build_context_block(docs_with_scores) ) active_system_prompt = MULTI_DOC_SYSTEM_PROMPT if is_multi else SYSTEM_PROMPT user_message = ( f"{context_str}\n\n" f"Question: {request.query}\n\n" f"Answer based solely on the context above:" ) try: import os os.makedirs("context", exist_ok=True) with open("context/query_context.txt", "w", encoding="utf-8") as f: f.write(f"--- Original Query ---\n{request.query}\n\n") f.write(f"--- Rewritten Query ---\n{retrieval_query}\n\n") f.write(f"--- Final Context ---\n{context_str}\n") except Exception as e: logger.warning(f"Failed to write query context to file: {e}") messages = build_lc_messages(trimmed_history, active_system_prompt) messages.append(HumanMessage(content=user_message)) # 8. Generate response = await _llm.ainvoke(messages) answer = response.content.strip() latency_ms = round((time.monotonic() - start)*1000,2) result = QueryResponse( answer = answer, sources=sources, session_id=request.session_id, rewritten_query=retrieval_query if retrieval_query != request.query else None, cached = False, latency_ms=latency_ms ) # 9. Cache the result if cache_allowed: result_dict = result.model_dump() set_exact(request.query, cache_collection_key, cache_params_key, result_dict) if cache_query_vec is None: cache_query_vec = await embed_query(request.query, CACHE_EMBEDDING_MODE) set_semantic(cache_query_vec, request.query, cache_collection_key, cache_params_key, result_dict) return result # Pipeline-events streaming variant (step-by-step SSE for frontend animation) async def pipeline_stream_query(request: QueryRequest) -> AsyncIterator[str]: """ Yields structured SSE JSON events for every step of the RAG pipeline, then streams LLM tokens one-by-one. Designed to drive frontend animations. Event types: pipeline_start, guardrail_check, cache_check, query_rewrite, retrieval_start, chunks_retrieved, context_built, generation_start, token, complete """ import json def _default(obj): """Fallback serialiser for types json.dumps can't handle natively.""" try: import numpy as np if isinstance(obj, np.integer): return int(obj) if isinstance(obj, np.floating): return float(obj) if isinstance(obj, np.bool_): return bool(obj) if isinstance(obj, np.ndarray): return obj.tolist() except ImportError: pass return str(obj) def emit(event: str, status: str, data: dict = None) -> str: payload = {"event": event, "status": status, "data": data or {}} return f"data: {json.dumps(payload, default=_default)}\n\n" start = time.monotonic() mode_val = request.retrieval_mode.value if hasattr(request.retrieval_mode, "value") else str(request.retrieval_mode) collections = request.doc_collections or [request.collection_name] embedding_mode = resolve_embedding_mode_for_collections(collections, request.embedding_mode) cache_allowed = settings.cache_enabled cache_collection_key = _cache_collection_key(collections) cache_params_key = _cache_params_key_v2( mode_val, request.top_k, request.top_k_retrieval, request.mmr_lambda, request.bm25_weight, request.vector_weight, ) yield emit("pipeline_start", "in_progress", { "query": request.query, "collection": request.collection_name, "mode": mode_val, "embedding_mode": embedding_mode, }) try: # --- Guardrail check --- guard = check_query(request.query) if not guard.allowed: yield emit("guardrail_check", "blocked", {"reason": guard.reason}) yield emit("complete", "blocked", { "answer": f"Request blocked: {guard.reason}", "sources": [], "latency_ms": round((time.monotonic() - start) * 1000, 2), }) yield "data: [DONE]\n\n" return yield emit("guardrail_check", "passed", {}) # --- Cache check --- cache_query_vec = None if cache_allowed: cached = get_exact(request.query, cache_collection_key, cache_params_key) if cached: cached["cached"] = True cached["latency_ms"] = round((time.monotonic() - start) * 1000, 2) yield emit("cache_check", "hit", {"type": "exact"}) yield emit("complete", "done", cached) yield "data: [DONE]\n\n" return cache_query_vec = await embed_query(request.query, CACHE_EMBEDDING_MODE) semantic_hit = get_semantic(cache_query_vec, cache_collection_key, cache_params_key) if semantic_hit: semantic_hit["cached"] = True semantic_hit["latency_ms"] = round((time.monotonic() - start) * 1000, 2) yield emit("cache_check", "hit", {"type": "semantic"}) yield emit("complete", "done", semantic_hit) yield "data: [DONE]\n\n" return yield emit("cache_check", "miss", {}) else: yield emit("cache_check", "skipped", {}) # --- Standalone question resolution (multi-turn) --- history = [h.model_dump() for h in request.history] trimmed_history = trim_history_to_budget(history) standalone = await resolve_standalone_question(request.query, trimmed_history, _llm) # --- Query rewrite --- if _should_preserve_exact_reference(standalone): retrieval_query = standalone yield emit("query_rewrite", "skipped", { "reason": "section/clause reference preserved", "query": standalone, }) else: retrieval_query = await rewrite_query(standalone) yield emit("query_rewrite", "done", { "original": request.query, "rewritten": retrieval_query, }) use_rag, history_answer = await _decide_rag_or_answer(standalone, trimmed_history, _llm) if not use_rag and history_answer: latency_ms = round((time.monotonic() - start) * 1000, 2) yield emit("rag_decision", "done", {"use_rag": False, "source": "history"}) yield emit("generation_start", "done", {"model": settings.chat_model, "source": "history"}) yield emit("complete", "done", { "answer": history_answer, "sources": [], "rewritten_query": retrieval_query if retrieval_query != request.query else None, "latency_ms": latency_ms, "session_id": request.session_id, "cached": False, }) yield "data: [DONE]\n\n" return yield emit("rag_decision", "done", {"use_rag": True}) # --- Document routing (multi-doc) --- if len(collections) > 1: scoped = detect_query_scope(retrieval_query, collections) is_multi = len(scoped) > 1 yield emit("doc_routing", "done", { "total_docs": len(collections), "selected": [c.split("__")[-1] for c in scoped], "mode": "comparison" if is_multi else "targeted", }) else: scoped = collections is_multi = False # --- Retrieval --- yield emit("retrieval_start", "in_progress", { "mode": mode_val, "top_k": request.top_k or settings.top_k_rerank, "top_k_retrieval": request.top_k_retrieval or settings.top_k_retrieval, "collections": len(scoped), }) if is_multi: k_per = max(3, (request.top_k or settings.top_k_rerank) // len(scoped)) docs_with_scores = await multi_collection_retrieve( query=retrieval_query, collections=scoped, mode=mode_val, k_per_collection=k_per, top_k_retrieval=request.top_k_retrieval, mmr_lambda=request.mmr_lambda, bm25_weight=request.bm25_weight, vector_weight=request.vector_weight, use_reranker=True, expand_context=True, ) else: docs_with_scores = await retrieve( query=retrieval_query, collection=scoped[0], mode=request.retrieval_mode, top_k=request.top_k, top_k_retrieval=request.top_k_retrieval, mmr_lambda=request.mmr_lambda, bm25_weight=request.bm25_weight, vector_weight=request.vector_weight, use_reranker=True, expand_context=True, ) if not docs_with_scores: yield emit("chunks_retrieved", "empty", {"count": 0}) yield emit("complete", "done", { "answer": "Can you clarify your question with a bit more detail (topic, document name, section, or timeframe)?", "sources": [], "rewritten_query": retrieval_query, "latency_ms": round((time.monotonic() - start) * 1000, 2), "session_id": request.session_id, "cached": False, }) yield "data: [DONE]\n\n" return chunk_previews = [ { "doc_id": doc.metadata.get("doc_id", "unknown")[:12], "score": round(float(score), 4), "preview": doc.page_content[:150] + "..." if len(doc.page_content) > 150 else doc.page_content, "source": doc.metadata.get("source_id", doc.metadata.get("source", "unknown")), "chunk_index": int(doc.metadata.get("chunk_index", 0)), } for doc, score in docs_with_scores ] yield emit("chunks_retrieved", "done", { "count": len(docs_with_scores), "chunks": chunk_previews, }) # --- Context building --- context_str, sources = ( build_grouped_context_block(docs_with_scores) if is_multi else build_context_block(docs_with_scores) ) active_system_prompt = MULTI_DOC_SYSTEM_PROMPT if is_multi else SYSTEM_PROMPT estimated_tokens = len(context_str) // 4 yield emit("context_built", "done", { "chunks_used": len(sources), "estimated_tokens": estimated_tokens, "sources": [{"doc_id": s.doc_id, "score": s.relevance_score} for s in sources], }) # --- LLM generation --- user_message = ( f"{context_str}\n\n" f"Question: {request.query}\n\n" f"Answer based solely on the context above:" ) messages = build_lc_messages(trimmed_history, active_system_prompt) messages.append(HumanMessage(content=user_message)) yield emit("generation_start", "in_progress", {"model": settings.chat_model}) llm_stream = _build_llm(streaming=True) full_answer = "" async for chunk in llm_stream.astream(messages): token = chunk.content if token: full_answer += token yield f"data: {json.dumps({'event': 'token', 'status': 'in_progress', 'data': {'text': token}})}\n\n" latency_ms = round((time.monotonic() - start) * 1000, 2) sources_data = [s.model_dump() for s in sources] # Cache result — failure must not crash the stream if cache_allowed: try: result_dict = { "answer": full_answer, "sources": sources_data, "session_id": request.session_id, "rewritten_query": retrieval_query if retrieval_query != request.query else None, "cached": False, "latency_ms": latency_ms, "eval_scores": None, } if cache_query_vec is None: cache_query_vec = await embed_query(request.query, CACHE_EMBEDDING_MODE) set_exact(request.query, cache_collection_key, cache_params_key, result_dict) set_semantic(cache_query_vec, request.query, cache_collection_key, cache_params_key, result_dict) except Exception: logger.warning("Cache write failed (non-fatal)", exc_info=True) yield emit("complete", "done", { "answer": full_answer, "sources": sources_data, "rewritten_query": retrieval_query if retrieval_query != request.query else None, "latency_ms": latency_ms, "session_id": request.session_id, "cached": False, }) yield "data: [DONE]\n\n" except Exception as exc: logger.exception("pipeline_stream_query crashed mid-stream") try: yield emit("complete", "failed", { "answer": f"Pipeline error: {exc}", "sources": [], "latency_ms": round((time.monotonic() - start) * 1000, 2), }) yield "data: [DONE]\n\n" except Exception: pass # Streaming variant async def stream_query(request: QueryRequest) -> AsyncIterator[str]: """ SSE-compatible streaming answer generator. Yields answer tokens as they arrive from OpenAI. Sources are emitted as a final JSON event. """ guard = check_query(request.query) if not guard.allowed: yield f"data: {guard.reason}\n\n" return standalone = await resolve_standalone_question( request.query, [h.model_dump() for h in request.history], _llm, ) if _should_preserve_exact_reference(standalone): retrieval_query = standalone logger.info("Skipping query rewrite to preserve section/clause reference: '%s'", standalone) else: retrieval_query = await rewrite_query(standalone) history = [h.model_dump() for h in request.history] trimmed_history = trim_history_to_budget(history) use_rag, history_answer = await _decide_rag_or_answer(standalone, trimmed_history, _llm) if not use_rag and history_answer: yield f"data: {history_answer}\n\n" return docs_with_scores = await retrieve( retrieval_query, request.collection_name, request.retrieval_mode.value ) context_str, sources = build_context_block(docs_with_scores) user_message = f"{context_str}\n\nQuestion: {request.query}\nAnswer:" llm_stream = _build_llm(streaming=True) async for chunk in llm_stream.astream([HumanMessage(content=user_message)]): token = chunk.content if token: yield f"data: {token}\n\n" import json sources_payload = [{"doc_id":s.doc_id, "score":s.relevance_score} for s in sources] yield f"data: [SOURCES]{json.dumps(sources_payload)}\n\n" yield "data: [DONE]\n\n" print("[query_engine] Module ready")