Spaces:
Paused
Paused
improved chat
Browse files- app.py +99 -17
- scripts/mintoak/chat_server.py +54 -9
app.py
CHANGED
|
@@ -103,16 +103,10 @@ PREAMBLE_RES = [
|
|
| 103 |
]
|
| 104 |
|
| 105 |
OPTIMIZER_PROMPT = (
|
| 106 |
-
"You are a search query optimizer. Your job is to translate user questions into database search keywords.
|
| 107 |
"Translate synonyms into our core product areas: 'onboarding/acquisition' -> 'DigiOnboard', "
|
| 108 |
-
"'payments' -> 'SmartPayments', 'voice/soundbox' -> 'SoundHub', 'cross-sell' -> 'SellSmart'.
|
| 109 |
-
"Output ONLY the keywords, separated by spaces. Do not write explanations or punctuation.
|
| 110 |
-
"Examples:\n"
|
| 111 |
-
"User: Tell me about Mintoak's loyalty program -> Keywords: loyalty program\n"
|
| 112 |
-
"User: How to onboard a merchant? -> Keywords: DigiOnboard merchant onboarding\n"
|
| 113 |
-
"User: What is the soundbox device? -> Keywords: SoundHub soundbox device\n"
|
| 114 |
-
"User: How do they accept payments? -> Keywords: SmartPayments accept payments\n"
|
| 115 |
-
"User: Tell me about business360 -> Keywords: business360"
|
| 116 |
)
|
| 117 |
|
| 118 |
SYSTEM_PROMPT = (
|
|
@@ -148,7 +142,8 @@ QUERY_ENHANCEMENT_RULES = [
|
|
| 148 |
},
|
| 149 |
{
|
| 150 |
"keywords": ["loyalty", "reward", "rewards", "campaign", "rewardrun"],
|
| 151 |
-
"expansion": "
|
|
|
|
| 152 |
"prompt_note": "Ensure you mention Mintoak RewardRun as the gamified loyalty and merchant engagement platform.",
|
| 153 |
"top_k": 3
|
| 154 |
}
|
|
@@ -156,6 +151,31 @@ QUERY_ENHANCEMENT_RULES = [
|
|
| 156 |
|
| 157 |
MAX_CHUNK_CHARS = 500
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
def retrieve_context(query: str, top_k: int = 2):
|
| 160 |
query_lower = query.lower()
|
| 161 |
search_keywords = None
|
|
@@ -164,7 +184,10 @@ def retrieve_context(query: str, top_k: int = 2):
|
|
| 164 |
# Step 1: Check config-driven expansion registry first
|
| 165 |
for rule in QUERY_ENHANCEMENT_RULES:
|
| 166 |
if any(kw in query_lower for kw in rule["keywords"]):
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
| 168 |
top_k = rule["top_k"]
|
| 169 |
prompt_note = rule.get("prompt_note")
|
| 170 |
break
|
|
@@ -173,6 +196,16 @@ def retrieve_context(query: str, top_k: int = 2):
|
|
| 173 |
if not search_keywords:
|
| 174 |
messages = [
|
| 175 |
{"role": "system", "content": OPTIMIZER_PROMPT},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
{"role": "user", "content": query}
|
| 177 |
]
|
| 178 |
prompt = _tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
|
@@ -231,9 +264,34 @@ def retrieve_context(query: str, top_k: int = 2):
|
|
| 231 |
|
| 232 |
return "\n\n---\n\n".join(context_parts), url_to_title, prompt_note
|
| 233 |
|
| 234 |
-
def clean_response(text: str) -> str:
|
| 235 |
for rx in PREAMBLE_RES:
|
| 236 |
text = rx.sub("", text).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
if text and text[0].islower():
|
| 238 |
text = text[0].upper() + text[1:]
|
| 239 |
return text
|
|
@@ -330,6 +388,7 @@ def index():
|
|
| 330 |
def chat():
|
| 331 |
data = request.get_json(force=True)
|
| 332 |
query = (data.get("query") or "").strip()
|
|
|
|
| 333 |
if not query:
|
| 334 |
return Response("data: " + json.dumps({"error": "Empty query"}) + "\n\n", mimetype="text/event-stream")
|
| 335 |
|
|
@@ -372,14 +431,22 @@ def chat():
|
|
| 372 |
def stream_rag():
|
| 373 |
yield f"data: {json.dumps({'event': 'sources', 'sources': sources})}\n\n"
|
| 374 |
|
|
|
|
|
|
|
|
|
|
| 375 |
user_content = f"Context:\n{context}\n\nQuestion: {query}"
|
| 376 |
if prompt_note:
|
| 377 |
user_content += f"\n\nNote: {prompt_note}"
|
| 378 |
|
| 379 |
-
messages
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
prompt = _tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 384 |
|
| 385 |
t0 = time.time()
|
|
@@ -403,21 +470,36 @@ def chat():
|
|
| 403 |
thread.start()
|
| 404 |
|
| 405 |
citation_started = False
|
|
|
|
| 406 |
for token_text in streamer:
|
| 407 |
full_response += token_text
|
| 408 |
|
| 409 |
if citation_started:
|
|
|
|
|
|
|
|
|
|
| 410 |
continue
|
| 411 |
|
| 412 |
last_snippet = full_response[-40:].lower()
|
| 413 |
if "👉" in token_text or "for more details" in last_snippet or "details, visit" in last_snippet:
|
| 414 |
citation_started = True
|
| 415 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
yield f"data: {json.dumps({'event': 'token', 'token': token_text})}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
latency = round(time.time() - t0, 2)
|
| 420 |
-
cleaned_response = clean_response(full_response.strip())
|
| 421 |
|
| 422 |
# If references were not generated, append them
|
| 423 |
if "Source:" not in cleaned_response and sources:
|
|
|
|
| 103 |
]
|
| 104 |
|
| 105 |
OPTIMIZER_PROMPT = (
|
| 106 |
+
"You are a search query optimizer. Your job is to translate user questions into database search keywords. "
|
| 107 |
"Translate synonyms into our core product areas: 'onboarding/acquisition' -> 'DigiOnboard', "
|
| 108 |
+
"'payments' -> 'SmartPayments', 'voice/soundbox' -> 'SoundHub', 'cross-sell' -> 'SellSmart'. "
|
| 109 |
+
"Output ONLY the keywords, separated by spaces. Do not write explanations or punctuation."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
)
|
| 111 |
|
| 112 |
SYSTEM_PROMPT = (
|
|
|
|
| 142 |
},
|
| 143 |
{
|
| 144 |
"keywords": ["loyalty", "reward", "rewards", "campaign", "rewardrun"],
|
| 145 |
+
"expansion": "loyalty_programs",
|
| 146 |
+
"override": True,
|
| 147 |
"prompt_note": "Ensure you mention Mintoak RewardRun as the gamified loyalty and merchant engagement platform.",
|
| 148 |
"top_k": 3
|
| 149 |
}
|
|
|
|
| 151 |
|
| 152 |
MAX_CHUNK_CHARS = 500
|
| 153 |
|
| 154 |
+
def has_lead_intent(query: str) -> bool:
|
| 155 |
+
q = query.lower()
|
| 156 |
+
# Check for demo requests
|
| 157 |
+
if any(kw in q for kw in ["book a demo", "schedule a demo", "request a demo", "book demo", "schedule demo", "request demo", "get a demo", "live demo"]):
|
| 158 |
+
return True
|
| 159 |
+
# Check for pricing/cost queries
|
| 160 |
+
if any(kw in q for kw in ["pricing", "price list", "pricing plans", "subscription cost", "licensing cost", "what are the charges", "setup fees"]):
|
| 161 |
+
return True
|
| 162 |
+
if "cost" in q and any(w in q for w in ["how much", "what is", "what's"]):
|
| 163 |
+
return True
|
| 164 |
+
if "how much" in q and any(w in q for w in ["cost", "charge", "pay", "fee"]):
|
| 165 |
+
return True
|
| 166 |
+
# Check for partnership queries
|
| 167 |
+
if any(kw in q for kw in ["become a partner", "partnering with you", "partnership opportunities", "partner with mintoak"]):
|
| 168 |
+
return True
|
| 169 |
+
# Check for contact/sales queries
|
| 170 |
+
if any(kw in q for kw in ["talk to sales", "sales team", "contact sales", "sales department", "sales contact"]):
|
| 171 |
+
return True
|
| 172 |
+
if any(kw in q for kw in ["get in touch", "how to contact", "contact us", "contact details", "callback", "call back"]):
|
| 173 |
+
return True
|
| 174 |
+
# Check for request to contact
|
| 175 |
+
if "call me" in q or "phone number" in q or "email address" in q:
|
| 176 |
+
return True
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
def retrieve_context(query: str, top_k: int = 2):
|
| 180 |
query_lower = query.lower()
|
| 181 |
search_keywords = None
|
|
|
|
| 184 |
# Step 1: Check config-driven expansion registry first
|
| 185 |
for rule in QUERY_ENHANCEMENT_RULES:
|
| 186 |
if any(kw in query_lower for kw in rule["keywords"]):
|
| 187 |
+
if rule.get("override"):
|
| 188 |
+
search_keywords = rule["expansion"]
|
| 189 |
+
else:
|
| 190 |
+
search_keywords = f"{query} {rule['expansion']}"
|
| 191 |
top_k = rule["top_k"]
|
| 192 |
prompt_note = rule.get("prompt_note")
|
| 193 |
break
|
|
|
|
| 196 |
if not search_keywords:
|
| 197 |
messages = [
|
| 198 |
{"role": "system", "content": OPTIMIZER_PROMPT},
|
| 199 |
+
{"role": "user", "content": "Tell me about Mintoak's loyalty program"},
|
| 200 |
+
{"role": "assistant", "content": "loyalty program"},
|
| 201 |
+
{"role": "user", "content": "How to onboard a merchant?"},
|
| 202 |
+
{"role": "assistant", "content": "DigiOnboard merchant onboarding"},
|
| 203 |
+
{"role": "user", "content": "What is the soundbox device?"},
|
| 204 |
+
{"role": "assistant", "content": "SoundHub soundbox device"},
|
| 205 |
+
{"role": "user", "content": "How do they accept payments?"},
|
| 206 |
+
{"role": "assistant", "content": "SmartPayments accept payments"},
|
| 207 |
+
{"role": "user", "content": "Tell me about business360"},
|
| 208 |
+
{"role": "assistant", "content": "business360"},
|
| 209 |
{"role": "user", "content": query}
|
| 210 |
]
|
| 211 |
prompt = _tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
|
|
|
| 264 |
|
| 265 |
return "\n\n---\n\n".join(context_parts), url_to_title, prompt_note
|
| 266 |
|
| 267 |
+
def clean_response(text: str, allow_lead_capture: bool = True) -> str:
|
| 268 |
for rx in PREAMBLE_RES:
|
| 269 |
text = rx.sub("", text).strip()
|
| 270 |
+
|
| 271 |
+
# Strip any model-generated citation lines
|
| 272 |
+
text = re.sub(r'(?i)(?:\n\s*)*👉?\s*For\s+more\s+details,?\s+visit:.*$', '', text).strip()
|
| 273 |
+
|
| 274 |
+
has_lead = "[CAPTURE_LEAD]" in text and allow_lead_capture
|
| 275 |
+
text_no_lead = text.replace("[CAPTURE_LEAD]", "").strip()
|
| 276 |
+
text_lower = text_no_lead.lower()
|
| 277 |
+
|
| 278 |
+
fallback_triggers = [
|
| 279 |
+
"not fully answered", "not mentioned", "not provided", "unable to answer",
|
| 280 |
+
"cannot be determined", "no mention", "not find any information",
|
| 281 |
+
"does not contain", "no information", "not in the context", "not explicitly mentioned",
|
| 282 |
+
"isn't mentioned", "not found"
|
| 283 |
+
]
|
| 284 |
+
if any(trigger in text_lower for trigger in fallback_triggers):
|
| 285 |
+
text_no_lead = (
|
| 286 |
+
"The requested information does not currently exist on www.mintoak.com. "
|
| 287 |
+
"You can get in touch with our team at https://www.mintoak.com/contact-us."
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
if has_lead:
|
| 291 |
+
text = text_no_lead + " [CAPTURE_LEAD]"
|
| 292 |
+
else:
|
| 293 |
+
text = text_no_lead
|
| 294 |
+
|
| 295 |
if text and text[0].islower():
|
| 296 |
text = text[0].upper() + text[1:]
|
| 297 |
return text
|
|
|
|
| 388 |
def chat():
|
| 389 |
data = request.get_json(force=True)
|
| 390 |
query = (data.get("query") or "").strip()
|
| 391 |
+
history = data.get("history") or []
|
| 392 |
if not query:
|
| 393 |
return Response("data: " + json.dumps({"error": "Empty query"}) + "\n\n", mimetype="text/event-stream")
|
| 394 |
|
|
|
|
| 431 |
def stream_rag():
|
| 432 |
yield f"data: {json.dumps({'event': 'sources', 'sources': sources})}\n\n"
|
| 433 |
|
| 434 |
+
num_user_questions = sum(1 for msg in history if msg.get("role") == "user") + 1
|
| 435 |
+
allow_lead_capture = has_lead_intent(query) or (num_user_questions >= 3)
|
| 436 |
+
|
| 437 |
user_content = f"Context:\n{context}\n\nQuestion: {query}"
|
| 438 |
if prompt_note:
|
| 439 |
user_content += f"\n\nNote: {prompt_note}"
|
| 440 |
|
| 441 |
+
# Build messages from system prompt + conversation history + current user query
|
| 442 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 443 |
+
for msg in history:
|
| 444 |
+
role = "user" if msg.get("role") == "user" else "assistant"
|
| 445 |
+
content = msg.get("content", "").strip()
|
| 446 |
+
content = content.replace("[CAPTURE_LEAD]", "").strip()
|
| 447 |
+
messages.append({"role": role, "content": content})
|
| 448 |
+
|
| 449 |
+
messages.append({"role": "user", "content": user_content})
|
| 450 |
prompt = _tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 451 |
|
| 452 |
t0 = time.time()
|
|
|
|
| 470 |
thread.start()
|
| 471 |
|
| 472 |
citation_started = False
|
| 473 |
+
lead_tag_sent = False
|
| 474 |
for token_text in streamer:
|
| 475 |
full_response += token_text
|
| 476 |
|
| 477 |
if citation_started:
|
| 478 |
+
if "[CAPTURE_LEAD]" in full_response and not lead_tag_sent and allow_lead_capture:
|
| 479 |
+
lead_tag_sent = True
|
| 480 |
+
yield f"data: {json.dumps({'event': 'token', 'token': '[CAPTURE_LEAD]'})}\n\n"
|
| 481 |
continue
|
| 482 |
|
| 483 |
last_snippet = full_response[-40:].lower()
|
| 484 |
if "👉" in token_text or "for more details" in last_snippet or "details, visit" in last_snippet:
|
| 485 |
citation_started = True
|
| 486 |
continue
|
| 487 |
+
|
| 488 |
+
if "[CAPTURE_LEAD]" in token_text:
|
| 489 |
+
if allow_lead_capture and not lead_tag_sent:
|
| 490 |
+
lead_tag_sent = True
|
| 491 |
+
yield f"data: {json.dumps({'event': 'token', 'token': '[CAPTURE_LEAD]'})}\n\n"
|
| 492 |
+
continue
|
| 493 |
|
| 494 |
yield f"data: {json.dumps({'event': 'token', 'token': token_text})}\n\n"
|
| 495 |
+
|
| 496 |
+
# Ensure lead tag is sent if generated or proactively triggered
|
| 497 |
+
should_send_lead = allow_lead_capture and ("[CAPTURE_LEAD]" in full_response or num_user_questions >= 3)
|
| 498 |
+
if should_send_lead and not lead_tag_sent:
|
| 499 |
+
yield f"data: {json.dumps({'event': 'token', 'token': ' [CAPTURE_LEAD]'})}\n\n"
|
| 500 |
|
| 501 |
latency = round(time.time() - t0, 2)
|
| 502 |
+
cleaned_response = clean_response(full_response.strip(), allow_lead_capture=allow_lead_capture)
|
| 503 |
|
| 504 |
# If references were not generated, append them
|
| 505 |
if "Source:" not in cleaned_response and sources:
|
scripts/mintoak/chat_server.py
CHANGED
|
@@ -119,7 +119,7 @@ QUERY_ENHANCEMENT_RULES = [
|
|
| 119 |
},
|
| 120 |
{
|
| 121 |
"keywords": ["document", "documents", "checklist", "paperwork", "certificates"],
|
| 122 |
-
"expansion": "
|
| 123 |
"prompt_note": "Ensure you list the specific documents needed: registration certificates, GST certificates, cancelled cheques, and board resolutions.",
|
| 124 |
"top_k": 3
|
| 125 |
},
|
|
@@ -128,11 +128,43 @@ QUERY_ENHANCEMENT_RULES = [
|
|
| 128 |
"expansion": "Mintoak DigiOnboard merchant onboarding KYC KYB acquisition banks",
|
| 129 |
"prompt_note": None,
|
| 130 |
"top_k": 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
]
|
| 133 |
|
| 134 |
MAX_CHUNK_CHARS = 400 # Truncate each chunk to cap prompt size & speed up prefill
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def retrieve_context(query: str, history: list = None, top_k: int = 2):
|
| 137 |
"""Return (context_text, url_to_title_dict, prompt_note) or ('OUT_OF_SCOPE_REFUSAL', {}, None)."""
|
| 138 |
query_lower = query.lower()
|
|
@@ -166,7 +198,10 @@ def retrieve_context(query: str, history: list = None, top_k: int = 2):
|
|
| 166 |
# Apply configuration-driven expansion rules dynamically (without wiping out original query keywords)
|
| 167 |
for rule in QUERY_ENHANCEMENT_RULES:
|
| 168 |
if any(kw in query_lower for kw in rule["keywords"]):
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
| 170 |
top_k = rule["top_k"]
|
| 171 |
prompt_note = rule.get("prompt_note")
|
| 172 |
break
|
|
@@ -216,7 +251,7 @@ def retrieve_context(query: str, history: list = None, top_k: int = 2):
|
|
| 216 |
return "\n\n---\n\n".join(context_parts), url_to_title, prompt_note
|
| 217 |
|
| 218 |
|
| 219 |
-
def clean_response(text: str) -> str:
|
| 220 |
"""Strip robotic preambles and fix capitalisation."""
|
| 221 |
for rx in PREAMBLE_RES:
|
| 222 |
text = rx.sub("", text).strip()
|
|
@@ -225,7 +260,7 @@ def clean_response(text: str) -> str:
|
|
| 225 |
text = re.sub(r'(?i)(?:\n\s*)*👉?\s*For\s+more\s+details,?\s+visit:.*$', '', text).strip()
|
| 226 |
|
| 227 |
# Handle LLM fallback non-compliance phrases and override with the brand-approved fallback
|
| 228 |
-
has_lead = "[CAPTURE_LEAD]" in text
|
| 229 |
text_no_lead = text.replace("[CAPTURE_LEAD]", "").strip()
|
| 230 |
text_lower = text_no_lead.lower()
|
| 231 |
|
|
@@ -432,6 +467,9 @@ def chat():
|
|
| 432 |
# First send sources so UI can render them instantly
|
| 433 |
yield f"data: {json.dumps({'event': 'sources', 'sources': sources})}\n\n"
|
| 434 |
|
|
|
|
|
|
|
|
|
|
| 435 |
# Rewrite the query passed to the LLM if it is a conversational feedback query
|
| 436 |
llm_query = query
|
| 437 |
if history:
|
|
@@ -478,7 +516,7 @@ def chat():
|
|
| 478 |
full_response += token_text
|
| 479 |
|
| 480 |
if citation_started:
|
| 481 |
-
if "[CAPTURE_LEAD]" in full_response and not lead_tag_sent:
|
| 482 |
lead_tag_sent = True
|
| 483 |
yield f"data: {json.dumps({'event': 'token', 'token': '[CAPTURE_LEAD]'})}\n\n"
|
| 484 |
continue
|
|
@@ -488,14 +526,21 @@ def chat():
|
|
| 488 |
citation_started = True
|
| 489 |
continue
|
| 490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
yield f"data: {json.dumps({'event': 'token', 'token': token_text})}\n\n"
|
| 492 |
|
| 493 |
-
# Ensure lead tag is sent if generated
|
| 494 |
-
|
| 495 |
-
|
|
|
|
| 496 |
|
| 497 |
latency = round(time.time() - t0, 2)
|
| 498 |
-
cleaned_response = clean_response(full_response.strip())
|
| 499 |
|
| 500 |
# If references were not generated in the response body, append them dynamically
|
| 501 |
if "Source:" not in cleaned_response and "details, visit:" not in cleaned_response and sources:
|
|
|
|
| 119 |
},
|
| 120 |
{
|
| 121 |
"keywords": ["document", "documents", "checklist", "paperwork", "certificates"],
|
| 122 |
+
"expansion": "What documents are needed for merchant onboarding? DigiOnboard KYC KYB",
|
| 123 |
"prompt_note": "Ensure you list the specific documents needed: registration certificates, GST certificates, cancelled cheques, and board resolutions.",
|
| 124 |
"top_k": 3
|
| 125 |
},
|
|
|
|
| 128 |
"expansion": "Mintoak DigiOnboard merchant onboarding KYC KYB acquisition banks",
|
| 129 |
"prompt_note": None,
|
| 130 |
"top_k": 2
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"keywords": ["loyalty", "reward", "rewards", "campaign", "rewardrun"],
|
| 134 |
+
"expansion": "loyalty_programs",
|
| 135 |
+
"override": True,
|
| 136 |
+
"prompt_note": "Ensure you mention Mintoak RewardRun as the gamified loyalty and merchant engagement platform.",
|
| 137 |
+
"top_k": 3
|
| 138 |
}
|
| 139 |
]
|
| 140 |
|
| 141 |
MAX_CHUNK_CHARS = 400 # Truncate each chunk to cap prompt size & speed up prefill
|
| 142 |
|
| 143 |
+
def has_lead_intent(query: str) -> bool:
|
| 144 |
+
q = query.lower()
|
| 145 |
+
# Check for demo requests
|
| 146 |
+
if any(kw in q for kw in ["book a demo", "schedule a demo", "request a demo", "book demo", "schedule demo", "request demo", "get a demo", "live demo"]):
|
| 147 |
+
return True
|
| 148 |
+
# Check for pricing/cost queries
|
| 149 |
+
if any(kw in q for kw in ["pricing", "price list", "pricing plans", "subscription cost", "licensing cost", "what are the charges", "setup fees"]):
|
| 150 |
+
return True
|
| 151 |
+
if "cost" in q and any(w in q for w in ["how much", "what is", "what's"]):
|
| 152 |
+
return True
|
| 153 |
+
if "how much" in q and any(w in q for w in ["cost", "charge", "pay", "fee"]):
|
| 154 |
+
return True
|
| 155 |
+
# Check for partnership queries
|
| 156 |
+
if any(kw in q for kw in ["become a partner", "partnering with you", "partnership opportunities", "partner with mintoak"]):
|
| 157 |
+
return True
|
| 158 |
+
# Check for contact/sales queries
|
| 159 |
+
if any(kw in q for kw in ["talk to sales", "sales team", "contact sales", "sales department", "sales contact"]):
|
| 160 |
+
return True
|
| 161 |
+
if any(kw in q for kw in ["get in touch", "how to contact", "contact us", "contact details", "callback", "call back"]):
|
| 162 |
+
return True
|
| 163 |
+
# Check for request to contact
|
| 164 |
+
if "call me" in q or "phone number" in q or "email address" in q:
|
| 165 |
+
return True
|
| 166 |
+
return False
|
| 167 |
+
|
| 168 |
def retrieve_context(query: str, history: list = None, top_k: int = 2):
|
| 169 |
"""Return (context_text, url_to_title_dict, prompt_note) or ('OUT_OF_SCOPE_REFUSAL', {}, None)."""
|
| 170 |
query_lower = query.lower()
|
|
|
|
| 198 |
# Apply configuration-driven expansion rules dynamically (without wiping out original query keywords)
|
| 199 |
for rule in QUERY_ENHANCEMENT_RULES:
|
| 200 |
if any(kw in query_lower for kw in rule["keywords"]):
|
| 201 |
+
if rule.get("override"):
|
| 202 |
+
search_query = rule["expansion"]
|
| 203 |
+
else:
|
| 204 |
+
search_query = f"{query} {rule['expansion']}"
|
| 205 |
top_k = rule["top_k"]
|
| 206 |
prompt_note = rule.get("prompt_note")
|
| 207 |
break
|
|
|
|
| 251 |
return "\n\n---\n\n".join(context_parts), url_to_title, prompt_note
|
| 252 |
|
| 253 |
|
| 254 |
+
def clean_response(text: str, allow_lead_capture: bool = True) -> str:
|
| 255 |
"""Strip robotic preambles and fix capitalisation."""
|
| 256 |
for rx in PREAMBLE_RES:
|
| 257 |
text = rx.sub("", text).strip()
|
|
|
|
| 260 |
text = re.sub(r'(?i)(?:\n\s*)*👉?\s*For\s+more\s+details,?\s+visit:.*$', '', text).strip()
|
| 261 |
|
| 262 |
# Handle LLM fallback non-compliance phrases and override with the brand-approved fallback
|
| 263 |
+
has_lead = "[CAPTURE_LEAD]" in text and allow_lead_capture
|
| 264 |
text_no_lead = text.replace("[CAPTURE_LEAD]", "").strip()
|
| 265 |
text_lower = text_no_lead.lower()
|
| 266 |
|
|
|
|
| 467 |
# First send sources so UI can render them instantly
|
| 468 |
yield f"data: {json.dumps({'event': 'sources', 'sources': sources})}\n\n"
|
| 469 |
|
| 470 |
+
num_user_questions = sum(1 for msg in history if msg.get("role") == "user") + 1
|
| 471 |
+
allow_lead_capture = has_lead_intent(query) or (num_user_questions >= 3)
|
| 472 |
+
|
| 473 |
# Rewrite the query passed to the LLM if it is a conversational feedback query
|
| 474 |
llm_query = query
|
| 475 |
if history:
|
|
|
|
| 516 |
full_response += token_text
|
| 517 |
|
| 518 |
if citation_started:
|
| 519 |
+
if "[CAPTURE_LEAD]" in full_response and not lead_tag_sent and allow_lead_capture:
|
| 520 |
lead_tag_sent = True
|
| 521 |
yield f"data: {json.dumps({'event': 'token', 'token': '[CAPTURE_LEAD]'})}\n\n"
|
| 522 |
continue
|
|
|
|
| 526 |
citation_started = True
|
| 527 |
continue
|
| 528 |
|
| 529 |
+
if "[CAPTURE_LEAD]" in token_text:
|
| 530 |
+
if allow_lead_capture and not lead_tag_sent:
|
| 531 |
+
lead_tag_sent = True
|
| 532 |
+
yield f"data: {json.dumps({'event': 'token', 'token': '[CAPTURE_LEAD]'})}\n\n"
|
| 533 |
+
continue
|
| 534 |
+
|
| 535 |
yield f"data: {json.dumps({'event': 'token', 'token': token_text})}\n\n"
|
| 536 |
|
| 537 |
+
# Ensure lead tag is sent if generated or proactively triggered
|
| 538 |
+
should_send_lead = allow_lead_capture and ("[CAPTURE_LEAD]" in full_response or num_user_questions >= 3)
|
| 539 |
+
if should_send_lead and not lead_tag_sent:
|
| 540 |
+
yield f"data: {json.dumps({'event': 'token', 'token': ' [CAPTURE_LEAD]'})}\n\n"
|
| 541 |
|
| 542 |
latency = round(time.time() - t0, 2)
|
| 543 |
+
cleaned_response = clean_response(full_response.strip(), allow_lead_capture=allow_lead_capture)
|
| 544 |
|
| 545 |
# If references were not generated in the response body, append them dynamically
|
| 546 |
if "Source:" not in cleaned_response and "details, visit:" not in cleaned_response and sources:
|