RRTest_Rag / scripts /mintoak /rag_assistant.py
Rutvij07's picture
Update assistant persona to Mintoak Website Assistant, add evaluation scripts and update data
5fc8a03
Raw
History Blame Contribute Delete
17.4 kB
import os
import json
import argparse
import chromadb
from chromadb.utils import embedding_functions
from mlx_lm import load, generate, stream_generate
from mlx_lm.sample_utils import make_sampler
# ==========================================
# 1. CONFIGURATION SECTION
# ==========================================
# This section defines the paths to our files, models, and databases.
# Having them here makes it easy to change them later if needed.
# Path to the directory where our local Vector Database (ChromaDB) stores its data
CHROMA_DB_PATH = "data/mintoak/chroma_db"
# The name of the collection (table) in our Vector Database
COLLECTION_NAME = "mintoak_content"
# Path to the file containing our prepared text passages (chunks)
CHUNKS_JSON_PATH = "data/mintoak/mintoak_chunks.json"
# The base language model we are loading from Hugging Face / local cache
MODEL_PATH = "mlx-community/Qwen2.5-1.5B-Instruct-4bit"
# Path to the directory containing our trained fine-tuning LoRA adapters
ADAPTER_PATH = "adapters/mintoak"
# ==========================================
# 2. HELPER FUNCTIONS
# ==========================================
def get_embedding_function():
"""
Creates and returns the embedding model.
Embeddings convert text sentences into lists of numbers (vectors)
so that the computer can search for documents by 'meaning' instead of exact keywords.
We use the SentenceTransformer 'all-MiniLM-L6-v2' which runs fully locally.
"""
return embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
def initialize_vector_db():
"""
Initializes ChromaDB and populates or updates it if content has changed.
This ensures that when you add new blogs or products to the JSON file,
the Vector Database is updated automatically without manual folder deletions.
"""
# Connect to the local database stored on your disk
client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
emb_fn = get_embedding_function()
# Get the existing collection, or create a new one if it doesn't exist
collection = client.get_or_create_collection(
name=COLLECTION_NAME,
embedding_function=emb_fn
)
# Check if the chunks file exists before trying to read it
if not os.path.exists(CHUNKS_JSON_PATH):
print(f"Error: Chunks file '{CHUNKS_JSON_PATH}' not found.")
print("Please run scripts/mintoak/prepare_data.py first to parse the raw data.")
return collection
# Load current chunks from the JSON file
with open(CHUNKS_JSON_PATH, "r", encoding="utf-8") as f:
chunks = json.load(f)
# Programmatically reset and reload the collection if the counts don't match
# (This triggers automatically when you add new data to your JSON source file)
if collection.count() != len(chunks):
print("Detected changes in chunks. Programmatically updating Vector DB...")
try:
existing = collection.get()
if existing and "ids" in existing and existing["ids"]:
ids_to_del = existing["ids"]
for j in range(0, len(ids_to_del), 500):
collection.delete(ids=ids_to_del[j:j+500])
except Exception as e:
print(f"Failed to clear existing database entries: {e}")
# Extract the fields we need to insert
ids = [c["id"] for c in chunks]
documents = [f"Title: {c['title']}\nContent: {c['content']}" for c in chunks]
metadatas = [{"url": c["url"], "title": c["title"], "category": c["category"]} for c in chunks]
# ChromaDB allows batch insertion; we insert in groups of 500
batch_size = 500
for i in range(0, len(chunks), batch_size):
collection.add(
ids=ids[i:i+batch_size],
documents=documents[i:i+batch_size],
metadatas=metadatas[i:i+batch_size]
)
print(f"Successfully updated index with {collection.count()} passages.")
else:
print(f"Vector DB is up-to-date with {collection.count()} passages.")
return collection
def retrieve_context(collection, query, top_k=3):
"""
Searches the Vector Database for the most relevant text passages matching the user's question.
"""
results = collection.query(
query_texts=[query],
n_results=top_k
)
query_lower = query.lower()
# Guardrail: Distance-based Grounding Filter
# Refuse questions that are semantically unrelated to Mintoak data (high distance)
# and do not explicitly mention Mintoak or its product lines.
best_distance = 999.0
if results and 'distances' in results and len(results['distances'][0]) > 0:
best_distance = results['distances'][0][0]
in_scope_keywords = [
# 1. Brand Names
"mintoak", "digiledge",
# 2. Product Names
"smartpayments", "digionboard", "rewardrun", "business360", "sellsmart", "staffaccess", "soundhub", "soundbox",
# 3. Leadership Names
"raman", "khanduja", "sanjay", "nazareth", "rama", "tadepalli", "kabeer", "jain", "rohit", "ramana",
# 4. FinTech Domain
"payment", "payments", "transaction", "transactions", "merchant", "merchants", "bank", "banks", "card", "cards",
"gpv", "volume", "acquiring", "acquirer", "acquirers", "credit", "lending", "loan", "loans", "invoicing",
"reconciliation", "settlement", "settlements",
# 5. MSME/SME
"sme", "smes", "msme", "msmes", "business", "businesses", "retail", "retailer", "retailers",
# 6. Onboarding & KYC
"onboard", "onboarding", "kyc", "kyb", "register", "registration", "account", "accounts", "signup", "signin",
"login", "log", "logout",
# 7. Security & Credentials
"password", "passwords", "reset", "otp", "credential", "credentials", "auth", "authentication", "security",
# 8. Contact & Offices
"address", "addresses", "office", "offices", "contact", "location", "locations", "headquarters", "registered",
"corporate", "callback", "call", "support", "help", "demo",
# 9. Careers
"job", "jobs", "career", "careers", "hiring", "opening", "openings", "join", "recruit", "recruitment",
# 10. Reports & Docs
"report", "reports", "statement", "statements", "download", "downloads", "dashboard",
# 11. Developer Resources
"api", "apis", "sdk", "sdks", "developer", "developers"
]
import re
is_mintoak_mention = any(re.search(rf"\b{re.escape(w)}\b", query_lower) for w in in_scope_keywords)
# Strict cut-off: if distance is extremely high, it's always out-of-scope
if best_distance > 0.82:
return "OUT_OF_SCOPE_REFUSAL", {}
# Moderate cut-off: allow bypass only if it has brand context
if best_distance > 0.60 and not is_mintoak_mention:
return "OUT_OF_SCOPE_REFUSAL", {}
# 1. If user is asking about products in general, ensure the master catalog is included
catalog_doc = None
catalog_meta = None
if any(pw in query_lower for pw in ["product", "products", "offering", "offerings", "catalog"]):
try:
catalog_res = collection.get(ids=["synthetic_master_catalog"])
if catalog_res and catalog_res.get('documents'):
catalog_doc = catalog_res['documents'][0]
catalog_meta = catalog_res['metadatas'][0]
except Exception as e:
print(f"Failed to retrieve product list catalog by ID: {e}")
context_parts = []
url_to_title = {}
if catalog_doc and catalog_meta:
context_parts.append(
f"Source: {catalog_meta['url']}\n"
f"Content: {catalog_doc}"
)
url_to_title[catalog_meta['url']] = catalog_meta.get('title', 'Mintoak Product List Catalog')
# If we found matches, format each match with its source URL and content
if results and 'documents' in results and len(results['documents'][0]) > 0:
indices = list(range(len(results['documents'][0])))
# Prioritize the Product List Catalog chunk if retrieved
for i in indices:
meta = results['metadatas'][0][i]
if meta.get('title') == "Mintoak Product List Catalog":
indices.remove(i)
indices.insert(0, i)
break
for idx in indices:
doc = results['documents'][0][idx]
meta = results['metadatas'][0][idx]
# Avoid duplicating the catalog if it was already prepended
if meta.get('title') == "Mintoak Product List Catalog" and catalog_doc is not None:
continue
context_parts.append(
f"Source: {meta['url']}\n"
f"Content: {doc}"
)
url_to_title[meta['url']] = meta.get('title', 'Learn more')
# Join the retrieved passages together separated by dashes
return "\n\n---\n\n".join(context_parts), url_to_title
# ==========================================
# 3. MAIN EXECUTION FLOW
# ==========================================
def main():
# Set up command-line arguments so users can pass their query when running the script
parser = argparse.ArgumentParser(description="Mintoak local RAG Assistant")
parser.add_argument("query", type=str, help="The question you want to ask the assistant")
parser.add_argument("--top_k", type=int, default=3, help="Number of content blocks to retrieve")
args = parser.parse_args()
# Guardrail Check: Pre-processing Safety & Prompt Injection Filter
# A lightweight filter to block offensive language and prompt injection attacks before loading the model
profane_patterns = [
"fuck", "shit", "asshole", "bitch", "bastard", "cunt", "dick", "pussy",
"idiot", "stupid", "dumbass", "kill yourself", "kys", "hate speech",
"retard", "wanker", "motherfucker"
]
injection_patterns = [
"ignore previous", "system prompt", "developer mode", "you are now",
"pretend to be", "reveal instructions", "system instruction", "dan mode",
"hypothetically speaking", "override rules", "jailbreak"
]
query_lower = args.query.lower()
if any(word in query_lower for word in profane_patterns):
print("\n--- Assistant Response ---")
print("I cannot assist with queries containing inappropriate or offensive language.")
print("--------------------------\n")
return
if any(pattern in query_lower for pattern in injection_patterns):
print("\n--- Assistant Response ---")
print("I am unable to comply with requests that attempt to modify or bypass system instructions.")
print("--------------------------\n")
return
# Step 1: Connect to or update the local Vector Database
collection = initialize_vector_db()
# Step 2: Retrieve context if needed, or bypass if it's a general greeting/identity question
print(f"\nRetrieving relevant content for: '{args.query}'...")
# Check if the question is a basic greeting or identity question
import re
query_clean = args.query.strip().lower().rstrip("?").strip()
# Matches variations of greetings like: hi, hii, hello, helloo, hey, heyy, yo, yoo, good morning, etc.
greeting_regex = r"^(hi+|hello+|hey+|yo+|greetings|good\s+(morning|afternoon|evening))\b"
is_greeting = bool(re.match(greeting_regex, query_clean))
identity_regex = r"\b(who are (you|u)|what is your name|what's your name|what do you do|introduce yourself|tell me about yourself)\b"
is_identity = bool(re.search(identity_regex, query_clean))
is_general = is_greeting or is_identity
if is_general:
print("\n--- Assistant Response ---")
if is_greeting:
print("Hello! 👋 I am the Mintoak Website Assistant. How can I help you with Mintoak's products or services today? 😊")
else:
print("I am the Mintoak Website Assistant! 🤖 I can help answer questions about Mintoak Innovations, our product catalog, services, office address, and guidelines. How can I assist you today? ✨")
print("--------------------------\n")
return
else:
# Search the database for relevant blogs/pages matching the user question
context, url_to_title = retrieve_context(collection, args.query, args.top_k)
if context == "OUT_OF_SCOPE_REFUSAL":
print("\n--- Assistant Response ---")
print("I am only authorized to assist with questions related to Mintoak Innovations Private Limited, its products, and services. How can I help you with Mintoak today? 😊")
print("--------------------------\n")
return
# The System instruction defines the persona, grounding rule, and formatting rules.
system_prompt = (
"You are the Mintoak Website Assistant. Speak in a friendly, fresh, and enthusiastic tone with expressive emojis! "
"Answer strictly using the provided context. Never invent, assume, or pull from outside knowledge. "
"Mintoak is strictly a bank-led B2B platform for merchants; Mintoak does NOT issue loyalty points or rewards to end-consumers. If asked how customers or end-consumers earn loyalty points, clarify that Mintoak does not directly run consumer loyalty programs, but enables merchants to set up their own custom campaigns and offers for their customers. Do not confuse B2B merchant app users with B2C end-consumers. "
"Start your response directly and naturally—do NOT use robotic preambles like 'As per the context' or 'Based on the provided context'. "
"Be highly concise (maximum 2-3 sentences or a short bulleted list). Cite the source URL from the context at the end of your response. "
"If the context does not contain the answer, reply: 'The requested information does not currently exist on www.mintoak.com. "
"You can get in touch with our team at https://www.mintoak.com/contact-us.'"
)
# Construct the user message matching the exact format the model was trained on
prompt = f"Context:\n{context}\n\nQuestion: {args.query}"
# Format the messages in standard chat roles
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
# Step 4: Load the base Qwen model along with our trained LoRA adapters
print("\nLoading model & adapter (this may take a few seconds on first run)...")
model, tokenizer = load(MODEL_PATH, adapter_path=ADAPTER_PATH)
# Step 5: Convert chat messages into the model's special token format (Chat Template)
formatted_prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# Step 6: Stream the response token-by-token for immediate output
print("\n--- Assistant Response ---")
import time
import re
import sys
start_time = time.time()
streamed_tokens = []
for token_response in stream_generate(
model,
tokenizer,
prompt=formatted_prompt,
max_tokens=256,
sampler=make_sampler(temp=0.0)
):
token_text = token_response.text
streamed_tokens.append(token_text)
print(token_text, end="", flush=True)
latency = time.time() - start_time
print() # newline after streamed output
response = "".join(streamed_tokens).strip()
# Clean up robotic preambles
preambles = [
r"^(?:as per|according to|based on)\s+(?:the\s+)?(?:context|provided context|mintoak documentation|documentation)(?:,\s*)?",
r"^based on what is provided in the context(?:,\s*)?",
r"^from the context(?:,\s*)?"
]
for pattern in preambles:
response = re.compile(pattern, re.IGNORECASE).sub("", response).strip()
if response and response[0].islower():
response = response[0].upper() + response[1:]
# Clean up learned dataset prefixes from the response
prefix_pattern = r"^Based on the Mintoak documentation,\s*(?:here is what we know about\s+[^:\n]+:)?\s*"
response = re.sub(prefix_pattern, "", response, flags=re.IGNORECASE).strip()
# If the response doesn't include a Source citation, append markdown source links
suffix = ""
if "Source:" not in response and context:
sources = []
for line in context.split("\n"):
if line.startswith("Source: "):
url = line.replace("Source: ", "").strip()
if url not in sources:
sources.append(url)
if sources:
markdown_sources = []
for url in sources:
title = url_to_title.get(url, "Learn more")
markdown_sources.append(f"[{title}]({url})")
suffix = "\n\n👉 For more details, visit: " + ", ".join(markdown_sources)
# Print only the suffix (source links) since the response was already streamed live above
if suffix:
print(suffix)
print(f"\n⏱️ Response generated in {latency:.2f} seconds.")
print("--------------------------\n")
if __name__ == "__main__":
main()