Multimodel_Rag / app /agent /agent.py
Dhrumil Parikh
deploy GeminiRAG
cdc55f4
Raw
History Blame Contribute Delete
12.8 kB
"""
Groq-powered document chat agent with Redis-backed session history.
Architecture
------------
run_agent() is the single entry point called by the /v1/agent/chat endpoint.
1. Intent classification (deterministic, no LLM):
chitchat — greetings and social filler → skip retrieval.
list_docs — "how many documents / files" → call list_documents() tool.
job_status — message contains a UUID and status/progress keywords →
call get_job_status() tool.
rag_query — everything else → hybrid ChromaDB retrieval.
2. Context injection:
The retrieved data (document list, job status, or chunk excerpts) is
appended to the user message inside <document_list>, <job_status>, or
<context> XML tags. The system prompt instructs the LLM to cite chunks
and refuse to answer from outside the provided context.
3. Single Groq LLM call (SYNTHESIS_MODEL = llama-3.1-8b-instant):
The assembled message history (system + last 10 turns + current user
message with injected context) is sent in one request.
4. Session persistence:
Full unbounded history is saved to Redis (SESSION_TTL = 7 days).
Only the last 10 messages are included in the LLM window to cap
context length. The injected context is stripped before saving so
history stays compact.
_retrieve_chunks() runs the same hybrid search (vector + BM25 + RRF +
cross-encoder rerank) as the main RAG engine, gated by CONFIDENCE_THRESHOLD.
"""
import json
import re
import uuid as _uuid
from datetime import date as _date
import groq as groq_sdk
import redis as redis_sdk
from app.agent.tools import (
get_job_status,
list_documents,
set_agent_user_id,
)
from app.config import settings
from app.observability.logging import get_logger
log = get_logger()
SYNTHESIS_MODEL = "llama-3.1-8b-instant"
SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
SESSION_KEY_PREFIX = "agent:session:"
# ---------------------------------------------------------------------------
# Session helpers (Redis-backed)
# ---------------------------------------------------------------------------
def _redis() -> redis_sdk.Redis:
return redis_sdk.from_url(settings.REDIS_URL, decode_responses=True)
def _load_session(session_id: str) -> list[dict]:
try:
data = _redis().get(f"{SESSION_KEY_PREFIX}{session_id}")
if data:
return json.loads(data)
except Exception as exc:
log.warning("session_load_error", session_id=session_id, error=str(exc))
return [{"role": "system", "content": SYSTEM_PROMPT}]
def _save_session(session_id: str, messages: list[dict]) -> None:
try:
_redis().setex(f"{SESSION_KEY_PREFIX}{session_id}", SESSION_TTL, json.dumps(messages))
except Exception as exc:
log.warning("session_save_error", session_id=session_id, error=str(exc))
# ---------------------------------------------------------------------------
# Direct ChromaDB retrieval — no extra LLM call
# ---------------------------------------------------------------------------
_AGENT_TOP_K = 5
_CHUNK_EXCERPT = 600 # chars per chunk (400-word chunks are smaller, show more)
def _retrieve_chunks(question: str) -> tuple[list[dict], float]:
"""Hybrid search: vector + BM25 via RRF. Returns (top-k chunks, top_vector_score).
The top_vector_score is the cosine similarity of the best vector hit and is
used for the confidence gate. RRF scores (on the merged chunks) are much
smaller and must NOT be used for the threshold check.
"""
from app.rag.embedder import embed_query
from app.rag.vectorstore import get_chroma_client, get_or_create_collection, search, rrf_merge
from app.rag.bm25_index import load_bm25, build_bm25, search_bm25
from app.rag.reranker import rerank
q_emb = embed_query(question, settings)
client = get_chroma_client(settings)
col = get_or_create_collection(client, settings)
vector_chunks = search(col, q_emb, top_k=_AGENT_TOP_K * 2)
# Capture cosine similarity BEFORE rrf_merge overwrites the score field
top_vector_score = vector_chunks[0]["score"] if vector_chunks else 0.0
index_data = load_bm25(settings) or build_bm25(col, settings)
bm25_chunks = search_bm25(index_data, question, top_k=_AGENT_TOP_K * 2)
rrf_chunks = rrf_merge(vector_chunks, bm25_chunks, top_k=_AGENT_TOP_K * 2)
return rerank(question, rrf_chunks, top_k=_AGENT_TOP_K), top_vector_score
def _build_context_block(chunks: list[dict]) -> str:
"""Format retrieved chunks as a compact numbered context block."""
parts = []
for i, c in enumerate(chunks, 1):
excerpt = c["text"][:_CHUNK_EXCERPT]
parts.append(f"[{i}] {c['filename']} ({c['page_or_segment']}): {excerpt}")
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Intent classification — deterministic, no LLM needed
# ---------------------------------------------------------------------------
_GREETING_RE = re.compile(
r"^(hi+|hello|hey|thanks?|thank\s+you|ok(ay)?|bye|good\s*(bye)?|great|"
r"perfect|got\s+it|sounds\s+good|alright|sure|cool|nice|yep|nope?|yes|no|"
r"awesome|makes\s+sense|understood)[!.,?'\s]*$",
re.IGNORECASE,
)
_LIST_DOCS_RE = re.compile(
r"("
# doc count / list queries
r"\b(?:list|show|what|which|how\s+many|tell\s+me|count|number\s+of|how\s+much)\b"
r".{0,30}"
r"\b(?:documents?|files?|uploaded|available|processed|stored|indexed|in\s+(?:the\s+)?(?:database|db|system|pipeline))\b"
r"|"
# chunk / embed / vector stats queries
r"\b(?:how\s+many|what\s+is\s+the|total|count\s+of)\b.{0,20}"
r"\b(?:chunks?|embeddings?|vectors?|embedded|indexed|pieces?)\b"
r"|"
# "stats / statistics" queries
r"\b(?:stats?|statistics|pipeline\s+info|system\s+info)\b"
r")",
re.IGNORECASE,
)
_JOB_STATUS_RE = re.compile(
r"\bjob[_\s-]?id\b|"
r"\b(status|progress|check).{0,20}\bjob\b|"
r"\bjob.{0,20}(status|progress|check)\b",
re.IGNORECASE,
)
_UUID_RE = re.compile(
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
re.IGNORECASE,
)
def _classify(message: str) -> str:
"""Returns: 'chitchat' | 'list_docs' | 'job_status' | 'rag_query'"""
stripped = message.strip()
if _GREETING_RE.match(stripped):
return "chitchat"
if _LIST_DOCS_RE.search(stripped):
return "list_docs"
if _JOB_STATUS_RE.search(stripped) and _UUID_RE.search(stripped):
return "job_status"
return "rag_query"
def _extract_uuid(text: str) -> str | None:
m = _UUID_RE.search(text)
return m.group(0) if m else None
# ---------------------------------------------------------------------------
# System prompt
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """You are an intelligent document assistant for MasterCRM.
When document context is provided between <context> tags:
- Answer ONLY using facts explicitly stated in that context.
- Cite every claim with [n] matching the numbered source.
- If the answer is not in the context, say: "I don't have that information in the provided documents."
- Never guess, infer, or use outside knowledge.
When a <document_list> is provided:
- Report "total_documents" as the exact document count.
- Report "total_chunks_embedded" as the total embedded chunk count.
- Do NOT mention list limits, truncation, or implementation details.
- Just state the numbers clearly and naturally.
Use conversation history to resolve follow-up questions (e.g. "what about them?" refers to the last entity discussed).
For greetings and chitchat, respond naturally without referencing documents."""
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def run_agent(message: str, user_id: str, session_id: str | None = None) -> dict:
if session_id is None:
session_id = str(_uuid.uuid4())
set_agent_user_id(user_id)
full_history = _load_session(session_id)
# Keep system prompt + last 10 messages to limit context growth
system_msg = full_history[0]
recent = full_history[-10:] if len(full_history) > 11 else full_history[1:]
messages = [system_msg] + recent
messages.append({"role": "user", "content": message})
client = groq_sdk.Groq(api_key=settings.GROQ_API_KEY)
tool_names_called: list[str] = []
prompt_tokens = 0
completion_tokens = 0
intent = _classify(message)
log.info("agent_intent", intent=intent, message_preview=message[:80])
# -----------------------------------------------------------------------
# Step 1 — Gather context based on intent
# -----------------------------------------------------------------------
context_injection = "" # extra text appended to the user turn
if intent == "list_docs":
result = list_documents()
tool_names_called.append("list_documents")
today = _date.today().strftime("%B %d, %Y")
context_injection = (
f"\n\n<document_list>\n"
f"Today's date: {today}\n"
f"{json.dumps(result, indent=2)}\n"
f"</document_list>"
)
elif intent == "job_status":
job_id = _extract_uuid(message)
if job_id:
result = get_job_status(job_id)
tool_names_called.append("get_job_status")
context_injection = f"\n\n<job_status>\n{json.dumps(result, indent=2)}\n</job_status>"
else:
intent = "rag_query"
if intent == "rag_query":
try:
chunks, top_vector_score = _retrieve_chunks(message)
if chunks and top_vector_score >= settings.CONFIDENCE_THRESHOLD:
context_block = _build_context_block(chunks)
context_injection = f"\n\n<context>\n{context_block}\n</context>"
tool_names_called.append("query_rag")
log.info(
"agent_rag_retrieved",
chunks=len(chunks),
top_vector_score=round(top_vector_score, 4),
)
elif chunks:
log.info("agent_rag_low_confidence", top_vector_score=round(top_vector_score, 4))
except Exception as exc:
log.error("agent_rag_error", error=str(exc)[:200])
# Inject retrieved context into the last user message
if context_injection:
messages[-1] = {
"role": "user",
"content": message + context_injection,
}
# -----------------------------------------------------------------------
# Step 2 — Single LLM call for synthesis
# -----------------------------------------------------------------------
final_text = ""
try:
resp = client.chat.completions.create(
model=SYNTHESIS_MODEL,
messages=messages,
max_tokens=512,
)
if resp.usage:
prompt_tokens = resp.usage.prompt_tokens or 0
completion_tokens = resp.usage.completion_tokens or 0
final_text = resp.choices[0].message.content or ""
# Store the clean user message (without injected context) in history
messages[-1] = {"role": "user", "content": message}
messages.append(resp.choices[0].message.model_dump(exclude_none=True))
except groq_sdk.RateLimitError as exc:
log.warning("agent_rate_limit", error=str(exc)[:200])
final_text = "I'm hitting a rate limit right now. Please wait a few seconds and try again."
messages[-1] = {"role": "user", "content": message}
except Exception as exc:
log.error("agent_synthesis_error", error=str(exc)[:300])
final_text = "Something went wrong generating a response. Please try again."
messages[-1] = {"role": "user", "content": message}
# Persist full unbounded history to Redis (trimming is only for the LLM window)
full_history.append({"role": "user", "content": message})
if final_text and not final_text.startswith("I'm hitting a rate limit") and not final_text.startswith("Something went wrong"):
full_history.append({"role": "assistant", "content": final_text})
_save_session(session_id, full_history)
log.info(
"agent_run_complete",
user_id=user_id,
session_id=session_id,
intent=intent,
tool_call_count=len(tool_names_called),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
)
return {
"response": final_text,
"tool_calls_made": tool_names_called,
"session_id": session_id,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
}