import asyncio
import logging
import os
import random
import string
import traceback
import uuid
from typing import Dict, List, Tuple
from app.services import session_store as _ss
import gradio as gr
import httpx
from app.core.config import get_settings
from app.services.embeddings import EmbeddingService
from app.services.llm import LLMService
from app.services.rag_pipeline import RAGPipeline
from app.services.vector_store import FaissVectorStore
from app.services.reranker import RerankerService
logger = logging.getLogger(__name__)
settings = get_settings()
API_URL = os.getenv("RAG_API_URL", "").strip()
# Initialize services
embedding_service = EmbeddingService(settings.embedding_model)
vector_store = FaissVectorStore(
embedding_service=embedding_service,
docs_dir=settings.docs_dir,
index_dir=settings.index_dir,
chunk_size_tokens=settings.chunk_size_tokens,
chunk_overlap_tokens=settings.chunk_overlap_tokens,
)
llm_service = LLMService(
provider=settings.llm_provider,
groq_api_key=settings.groq_api_key,
groq_model=settings.groq_model,
groq_rewrite_model=settings.groq_rewrite_model,
hf_api_key=settings.hf_api_key,
hf_model=settings.hf_model,
timeout_s=settings.request_timeout_s,
)
reranker_service = RerankerService(settings.reranker_model)
pipeline = RAGPipeline(
vector_store=vector_store,
llm_service=llm_service,
reranker=reranker_service,
top_k=settings.top_k,
max_context_chunks=settings.max_context_chunks
)
# Ensure index is ready
vector_store.build_or_load()
# ── Keyboard layout for realistic typos ──────────────────────────────
NEARBY_KEYS = {
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf',
'f': 'dgrtcv', 'g': 'fhtybn', 'h': 'gjyunb', 'i': 'uojkl', 'j': 'hkunmi',
'k': 'jlomi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'ipkl',
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
'u': 'yihj', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
'z': 'xas',
}
TYPO_CHANCE = 0.07 # 7% chance per word
def _nearby_char(ch: str) -> str:
"""Return a plausible neighbouring key for the given character."""
lower = ch.lower()
if lower in NEARBY_KEYS:
replacement = random.choice(NEARBY_KEYS[lower])
return replacement.upper() if ch.isupper() else replacement
return random.choice(string.ascii_lowercase)
def _to_history(messages: List[Dict[str, str]]) -> list:
"""Returns the history as a list of dicts (already in this format for Gradio 5)."""
return messages
async def _chat_via_api(message: str, history: List[Dict[str, str]]) -> str:
payload = {"message": message, "history": history}
headers = {"Content-Type": "application/json"}
if settings.api_key:
headers["x-api-key"] = settings.api_key
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(API_URL, json=payload, headers=headers)
if resp.status_code == 429:
return "⚠️ I'm receiving too many requests right now. Please wait a moment before sending another message."
resp.raise_for_status()
return resp.json()["reply"]
async def _get_reply(message: str, chat_history: List[Dict[str, str]]) -> str:
"""Fetch the full reply from the LLM (API or direct pipeline)."""
if API_URL:
return await _chat_via_api(message, chat_history)
result = await pipeline.chat(message=message, history=chat_history)
return result["reply"]
async def chat_fn(message: str, chat_history: List[Dict[str, str]], session_id: str = ""):
"""Streaming generator: yields word-by-word with human-like timing & typos."""
if not message or not message.strip():
yield gr.update(interactive=True), chat_history
return
original_history = chat_history.copy()
chat_history = chat_history + [
{"role": "user", "content": message},
{"role": "assistant", "content": ""}
]
yield gr.update(value="", interactive=False), chat_history
try:
reply = await _get_reply(message, original_history)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
reply = "⚠️ Rate limit reached. Please slow down a bit!" if e.response.status_code == 429 \
else "⚠️ I encountered an error. Please try again in a few seconds."
except Exception as e:
logger.error(f"Unexpected error: {str(e)}\n{traceback.format_exc()}")
reply = f"⚠️ Oops! Something went wrong."
# ── Humanistic letter-by-letter typing — HTML-safe ────────────────────
displayed = "" # what is shown in the chat bubble
buffer = "" # invisible accumulator for mid-HTML-tag characters
in_tag = False # True while we are inside a < … > sequence
for char in reply:
if char == '<':
in_tag = True
buffer += char
continue
if in_tag:
buffer += char
if char == '>':
# Tag is now complete — flush it to the display all at once
in_tag = False
displayed += buffer
buffer = ""
chat_history[-1] = {"role": "assistant", "content": displayed}
yield gr.update(value="", interactive=False), chat_history
# Small pause after a
to mimic line-break pacing
if displayed.endswith('
') or displayed.endswith('
'):
await asyncio.sleep(random.uniform(0.1, 0.2))
continue
# ── Normal character (not inside a tag) ───────────────────────────
# Decide typo chance only for plain alphabetic words
do_typo = (
char.isalpha()
and len(displayed) > 8 # skip the very beginning
and random.random() < TYPO_CHANCE
)
if do_typo:
wrong_char = _nearby_char(char)
displayed += wrong_char
chat_history[-1] = {"role": "assistant", "content": displayed}
yield gr.update(value="", interactive=False), chat_history
await asyncio.sleep(random.uniform(0.15, 0.3))
# Backspace the wrong character
displayed = displayed[:-1]
chat_history[-1] = {"role": "assistant", "content": displayed}
yield gr.update(value="", interactive=False), chat_history
await asyncio.sleep(random.uniform(0.1, 0.2))
# Type the correct character
displayed += char
chat_history[-1] = {"role": "assistant", "content": displayed}
yield gr.update(value="", interactive=False), chat_history
# Pacing: punctuation pauses, space micro-pause, normal char speed
if char in '.!?':
await asyncio.sleep(random.uniform(0.35, 0.7))
elif char in ',:;':
await asyncio.sleep(random.uniform(0.15, 0.3))
elif char == ' ':
await asyncio.sleep(random.uniform(0.06, 0.13))
else:
await asyncio.sleep(random.uniform(0.02, 0.06))
# ── Fire-and-forget: persist to session store (never blocks the UI) ───
# We only save successful responses, not error messages
if session_id and not displayed.startswith("⚠️"):
asyncio.create_task(_ss.save_message(session_id, message, displayed))
# Re-enable the input box at the end
yield gr.update(interactive=True), chat_history
custom_css = """
/* Aggressively force height: auto and min-height: 40px on the chatbot and flex containers */
.gradio-container, .flex, .block, #chatbot-window {
height: auto !important;
min-height: 40px !important;
}
/* Ensure the chatbot doesn't overflow the viewport if it gets too full */
#chatbot-window {
max-height: 70vh !important;
border: none !important;
}
/* Aggressively hide the footer, including HF injected footers if inside the container */
footer, .footer, footer * {
display: none !important;
visibility: hidden !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
"""
# JS: runs immediately on page load — generates/loads session_id from localStorage
# and restores previous chat history. Completely non-blocking.
_SESSION_JS = """
async () => {
// ── Session ID: generate once, persist forever in localStorage ──
let sid = localStorage.getItem('martech_session_id');
if (!sid) {
sid = 'sess-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
localStorage.setItem('martech_session_id', sid);
}
// Write the session_id into the hidden Gradio textbox
const el = document.getElementById('session-id-box')?.querySelector('textarea');
if (el) { el.value = sid; el.dispatchEvent(new Event('input', {bubbles:true})); }
return sid;
}
"""
with gr.Blocks(title="Martechsol Assistant", theme=gr.themes.Soft(), css=custom_css) as demo:
gr.Markdown("# Martechsol Assistant")
gr.Markdown("Welcome! How can I help you today?")
chatbot = gr.Chatbot(
show_label=False,
elem_id="chatbot-window",
render_markdown=True,
)
with gr.Row():
msg = gr.Textbox(
placeholder="Type your question here...",
show_label=False,
scale=9
)
send = gr.Button("Send", variant="primary", scale=1)
clear = gr.Button("Clear Chat History")
# Hidden textbox holds the session_id — invisible to the user
session_id = gr.Textbox(
value="",
visible=False,
elem_id="session-id-box",
label="session_id",
)
# On load: run JS to set session_id from localStorage (non-blocking)
demo.load(fn=None, inputs=None, outputs=session_id, js=_SESSION_JS)
# Wire up the events — streaming generator for live typing effect (UNCHANGED)
send.click(chat_fn, inputs=[msg, chatbot, session_id], outputs=[msg, chatbot])
msg.submit(chat_fn, inputs=[msg, chatbot, session_id], outputs=[msg, chatbot])
clear.click(lambda: [], None, chatbot)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)