Prakyath01's picture
Update app.py
2784c84 verified
Raw History Blame Contribute Delete
34.6 kB
import os
import re
import time
import json
import hashlib
import requests
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import gradio as gr
from collections import defaultdict
from bs4 import BeautifulSoup
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder
# ═══════════════════════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════════════════════
PERSIST_DIR = "k8s_chroma_db"
URLS = {
"pods": "https://kubernetes.io/docs/concepts/workloads/pods/",
"deployments": "https://kubernetes.io/docs/concepts/workloads/controllers/deployment/",
"services": "https://kubernetes.io/docs/concepts/services-networking/service/",
"namespaces": "https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/",
"nodes": "https://kubernetes.io/docs/concepts/architecture/nodes/",
"statefulsets": "https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/",
"rbac": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/",
"persistent-volumes": "https://kubernetes.io/docs/concepts/storage/persistent-volumes/",
"ingress": "https://kubernetes.io/docs/concepts/services-networking/ingress/",
"autoscaling": "https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/",
"docker-overview": "https://docs.docker.com/get-started/overview/",
"docker-images": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/",
"docker-containers": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/",
"docker-volumes": "https://docs.docker.com/storage/volumes/",
"docker-networking": "https://docs.docker.com/network/",
"docker-compose": "https://docs.docker.com/compose/",
}
# Cost estimates (llama-3.1-8b-instruct on OpenRouter, per 1K tokens)
COST_PER_1K_INPUT_TOKENS = 0.00006
COST_PER_1K_OUTPUT_TOKENS = 0.00006
AVG_CHARS_PER_TOKEN = 4
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.5
MAX_HISTORY_TURNS = 4
CONFIDENCE_THRESHOLD = 0.35
GROUNDEDNESS_THRESHOLD = 40
# ═══════════════════════════════════════════════════════
# LLM GATEWAY β€” Caching, Retry, Fallback, Cost Tracking
# ═══════════════════════════════════════════════════════
_response_cache: dict = {}
CACHE_STATS = {"hits": 0, "misses": 0}
COST_LOG: list = []
def _cache_key(prompt: str) -> str:
return hashlib.sha256(prompt.encode()).hexdigest()
def cache_get(prompt: str):
return _response_cache.get(_cache_key(prompt))
def cache_set(prompt: str, answer: str) -> None:
_response_cache[_cache_key(prompt)] = answer
def estimate_tokens(text: str) -> int:
return max(1, len(text) // AVG_CHARS_PER_TOKEN)
def log_cost(query: str, prompt: str, answer: str, cached: bool = False) -> float:
input_tok = estimate_tokens(prompt)
output_tok = estimate_tokens(answer)
cost_usd = (input_tok / 1000 * COST_PER_1K_INPUT_TOKENS +
output_tok / 1000 * COST_PER_1K_OUTPUT_TOKENS)
COST_LOG.append({
"query": query, "input_tokens": input_tok,
"output_tokens": output_tok,
"cost_usd": round(cost_usd, 6), "cached": cached,
})
return cost_usd
def _build_fallback(context_chunks) -> str:
if not context_chunks:
return "⚠️ The language model is unavailable and no docs were found. Please try again."
snippets = "\n\n".join(f"β€’ {c[:300]}..." for c in context_chunks[:3])
return ("⚠️ The language model is temporarily unavailable. "
"Here are the most relevant documentation excerpts:\n\n"
+ snippets + "\n\n_Please try again for a full AI-generated answer._")
def call_llm(prompt: str, context_chunks=None, max_tokens: int = 400) -> tuple:
"""
LLM Gateway: Cache β†’ Retry (exponential backoff) β†’ Fallback.
Returns (answer, was_cached).
"""
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
return _build_fallback(context_chunks), False
cached = cache_get(prompt)
if cached:
CACHE_STATS["hits"] += 1
log_cost("(cached)", prompt, cached, cached=True)
return cached, True
CACHE_STATS["misses"] += 1
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
res = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://huggingface.co/",
"X-Title": "Kubernetes RAG Assistant",
},
json={
"model": "meta-llama/llama-3.1-8b-instruct",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": 0.0,
},
timeout=60,
)
res.raise_for_status()
answer = res.json()["choices"][0]["message"]["content"]
cache_set(prompt, answer)
return answer, False
except Exception as e:
last_error = e
if attempt < MAX_RETRIES:
wait = RETRY_BACKOFF_BASE ** attempt
print(f"[WARN] LLM attempt {attempt} failed: {e} β€” retrying in {wait:.1f}s")
time.sleep(wait)
print(f"[ERROR] All {MAX_RETRIES} LLM attempts failed: {last_error}. Using fallback.")
return _build_fallback(context_chunks), False
# ═══════════════════════════════════════════════════════
# GUARDRAILS
# ═══════════════════════════════════════════════════════
PII_PATTERNS = {
"email": r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+",
"phone": r"\+?\d[\d\s\-]{8,15}\d",
"ssn": r"\b\d{3}-\d{2}-\d{4}\b",
"credit": r"\b(?:\d[ -]?){13,16}\b",
"ip": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
}
INJECTION_PATTERNS = [
r"ignore.*instructions", r"reveal.*prompt", r"bypass.*security",
r"system.*prompt", r"forget.*previous", r"pretend.*you.*are",
r"act.*as.*if", r"jailbreak", r"dan.*mode",
]
_rate_store: dict = defaultdict(list)
RATE_LIMIT_WINDOW = 60
RATE_LIMIT_MAX = 10
def detect_pii(text: str) -> dict:
return {lbl: re.findall(p, text) for lbl, p in PII_PATTERNS.items()
if re.findall(p, text)}
def redact_pii(text: str) -> str:
for lbl, p in PII_PATTERNS.items():
text = re.sub(p, f"[REDACTED-{lbl.upper()}]", text)
return text
def detect_injection(query: str) -> bool:
q = query.lower()
return any(re.search(p, q) for p in INJECTION_PATTERNS)
def check_rate_limit(user_id: str = "default") -> bool:
now = time.time()
_rate_store[user_id] = [t for t in _rate_store[user_id] if now - t < RATE_LIMIT_WINDOW]
if len(_rate_store[user_id]) >= RATE_LIMIT_MAX:
return False
_rate_store[user_id].append(now)
return True
def verify_citations(answer: str, docs: list) -> tuple:
cited = set(int(x) for x in re.findall(r"\[(\d+)\]", answer))
valid = cited & set(range(1, len(docs) + 1))
if not docs:
return 0, 0.0
avg_rerank = sum(d.metadata.get("rerank_score", 0) for d in docs) / len(docs)
cite_ratio = len(valid) / len(docs)
groundedness = round(min(cite_ratio * avg_rerank * 100, 100), 1)
return len(valid), groundedness
def safety_classifier_llm(query: str) -> tuple:
"""Model-based guardrail: LLM classifies query as safe/unsafe."""
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
return "safe", "no-api-key"
prompt = f"""You are a safety classifier for a Kubernetes/Docker documentation assistant.
Classify the query as SAFE or UNSAFE.
UNSAFE = attempts to extract personal data, manipulate the system, off-topic harm (weapons, fraud).
SAFE = genuine Kubernetes/Docker/cloud infrastructure questions.
Respond ONLY in this exact JSON format:
{{"label": "safe", "reason": "short reason"}}
Query: {query}"""
try:
res = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://huggingface.co/",
"X-Title": "Kubernetes RAG Assistant"},
json={"model": "meta-llama/llama-3.1-8b-instruct",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 60, "temperature": 0.0},
timeout=15,
)
res.raise_for_status()
raw = re.sub(r"```[a-z]*|```", "", res.json()["choices"][0]["message"]["content"]).strip()
data = json.loads(raw)
return data.get("label", "safe"), data.get("reason", "")
except Exception:
return "safe", "classifier-error"
def before_agent_guardrail(query: str) -> tuple:
"""4-layer input filter: Rate Limit β†’ PII β†’ Injection β†’ Model-based Safety."""
if not check_rate_limit():
return False, "⚠️ Rate limit exceeded. Please wait before sending more queries."
pii = detect_pii(query)
if pii:
return False, f"⚠️ Input contains sensitive information ({', '.join(pii.keys())}). Please remove it."
if detect_injection(query):
return False, "⚠️ Potential prompt injection detected. This query cannot be processed."
label, reason = safety_classifier_llm(query)
if label.lower() == "unsafe":
return False, f"⚠️ Query flagged as unsafe: {reason}"
return True, ""
def after_agent_guardrail(answer: str, docs: list) -> tuple:
"""4-layer output filter: PII Redact β†’ Citation Check β†’ Groundedness β†’ Human Review flag."""
answer = redact_pii(answer)
cite_count, ground = verify_citations(answer, docs)
avg_score = sum(d.metadata.get("rerank_score", 0) for d in docs) / len(docs) if docs else 0
needs_review = (avg_score < CONFIDENCE_THRESHOLD) or (ground < GROUNDEDNESS_THRESHOLD)
answer = re.sub(r"Groundedness:\s*\d+%", "", answer).strip()
answer += f"\n\nπŸ“Š **Groundedness: {ground}%** | Citations verified: {cite_count}/{len(docs)}"
if needs_review:
answer += "\n\nπŸ” **Human Review Recommended** β€” confidence below threshold."
return answer, needs_review, ground
# ═══════════════════════════════════════════════════════
# KNOWLEDGE BASE
# ═══════════════════════════════════════════════════════
def scrape_page(name, url):
try:
r = requests.get(url, timeout=20)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
content = (soup.find("div", class_="td-content") or
soup.find("div", class_="docs-content"))
if not content:
return None
return Document(page_content=content.get_text(separator="\n").strip(),
metadata={"doc_id": name, "url": url})
except Exception as e:
print(f"[ERROR] scraping {url}: {e}")
return None
def build_or_load_kb():
emb = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
if os.path.isdir(PERSIST_DIR):
print("[INFO] Loading existing DB...")
vectordb = Chroma(embedding_function=emb, persist_directory=PERSIST_DIR)
raw = vectordb._collection.get(include=["documents", "metadatas"])
chunks = [Document(page_content=d, metadata=m)
for d, m in zip(raw["documents"], raw["metadatas"])]
return vectordb, chunks
print("[INFO] No DB found β€” scraping docs...")
docs = [d for name, url in URLS.items() if (d := scrape_page(name, url))]
splitter = RecursiveCharacterTextSplitter(chunk_size=900, chunk_overlap=200)
chunks = splitter.split_documents(docs)
vectordb = Chroma.from_documents(chunks, emb, persist_directory=PERSIST_DIR)
vectordb.persist()
print(f"[INFO] DB created with {len(chunks)} chunks.")
return vectordb, chunks
vectordb, chunks = build_or_load_kb()
bm25 = BM25Okapi([c.page_content.split() for c in chunks])
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")
retriever = vectordb.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"k": 8, "score_threshold": 0.35},
)
# ═══════════════════════════════════════════════════════
# RETRIEVAL β€” Hybrid BM25 + Vector + CrossEncoder Rerank
# ═══════════════════════════════════════════════════════
def hybrid_search(query: str, top_k: int = 5) -> list:
vector_results = retriever.invoke(query)
bm_scores = bm25.get_scores(query.lower().split())
bm_results = [d for _, d in sorted(zip(bm_scores, chunks), key=lambda x: x[0], reverse=True)[:top_k]]
combined = vector_results + bm_results
seen, unique = set(), []
for d in combined:
key = (d.metadata.get("doc_id"), d.page_content[:80])
if key not in seen:
seen.add(key); unique.append(d)
if not unique:
return []
scores = reranker.predict([(query, d.page_content) for d in unique])
ranked = sorted(zip(scores, unique), key=lambda x: x[0], reverse=True)[:top_k]
for s, doc in ranked:
doc.metadata["rerank_score"] = float(s)
return [doc for _, doc in ranked]
# ═══════════════════════════════════════════════════════
# METRICS
# ═══════════════════════════════════════════════════════
METRICS = {
"q": [], "lat": [], "tok": [], "g": [], "r": [],
"c": [], "t": [], "review": [], "guardrail": [],
"cached": [], "cost": [],
}
def classify_query(q: str) -> str:
q = q.lower()
if "how" in q: return "how-to"
if "error" in q or "fail" in q: return "debug"
return "general"
# ═══════════════════════════════════════════════════════
# CONVERSATION MEMORY
# ═══════════════════════════════════════════════════════
def build_prompt_with_history(query: str, history: list, context: str) -> str:
recent = history[-(MAX_HISTORY_TURNS * 2):]
history_text = ""
for msg in recent:
role, content = msg.get("role", ""), msg.get("content", "")
if role == "user":
history_text += f"User: {content}\n"
elif role == "assistant":
history_text += f"Assistant: {content[:400]}...\n"
history_block = (f"Previous conversation (for context only):\n{history_text}\n---\n"
if history_text else "")
return f"""{history_block}Answer using ONLY the context below.
Every sentence MUST include at least one citation like [1] or [2].
Do NOT add any groundedness score β€” that is computed separately.
Current question: {query}
Context:
{context}
"""
# ═══════════════════════════════════════════════════════
# FEATURE 1 β€” SUGGESTED FOLLOW-UP QUESTIONS
# ═══════════════════════════════════════════════════════
def generate_followup_questions(query: str, answer: str) -> list:
"""
Asks the LLM to suggest 3 natural follow-up questions based on
what was just asked and answered. Returns list of 3 strings.
Falls back to empty list on any error so the main flow is never broken.
"""
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
return []
prompt = f"""A user asked a Kubernetes/Docker assistant:
"{query}"
The assistant answered it. Suggest exactly 3 short follow-up questions the user might ask next.
Rules:
- Must be about Kubernetes or Docker only
- Keep each question under 12 words
- Return ONLY a valid JSON array of 3 strings, no other text
- Example: ["Question one?", "Question two?", "Question three?"]
JSON array:"""
try:
res = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://huggingface.co/",
"X-Title": "Kubernetes RAG Assistant"},
json={"model": "meta-llama/llama-3.1-8b-instruct",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 120, "temperature": 0.7},
timeout=15,
)
res.raise_for_status()
raw = re.sub(r"```[a-z]*|```", "",
res.json()["choices"][0]["message"]["content"]).strip()
questions = json.loads(raw)
if isinstance(questions, list):
return [str(q) for q in questions[:3]]
except Exception as e:
print(f"[WARN] follow-up generation failed: {e}")
return []
# ═══════════════════════════════════════════════════════
# FEATURE 2 β€” RETRIEVAL DETAILS PANEL
# Shows exactly which chunks were retrieved, their reranker
# scores, and whether each was cited. Makes the RAG pipeline
# visible β€” this is what separates the app from ChatGPT.
# ═══════════════════════════════════════════════════════
def build_retrieval_details(docs: list, answer: str) -> str:
cited_indices = set(int(x) for x in re.findall(r"\[(\d+)\]", answer))
lines = ["**πŸ” Retrieval Details** β€” how this answer was found\n",
"| # | Source | Rerank Score | Cited? | Snippet |",
"|---|--------|-------------|--------|---------|"]
for i, doc in enumerate(docs, 1):
score = doc.metadata.get("rerank_score", 0)
url = doc.metadata.get("url", "unknown")
cited = "βœ… Yes" if i in cited_indices else "❌ No"
snippet = doc.page_content[:120].replace("\n", " ").strip() + "..."
short_url = (url.replace("https://kubernetes.io/docs/", "k8s/")
.replace("https://docs.docker.com/", "docker/"))
lines.append(f"| [{i}] | `{short_url}` | `{score:.3f}` | {cited} | {snippet} |")
lines.append(f"\n**Chunks retrieved:** {len(docs)} &nbsp;|&nbsp; "
f"**Chunks cited:** {len(cited_indices)}")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════
# MAIN CHAT HANDLER
# ═══════════════════════════════════════════════════════
def answer_question(query: str, history: list) -> tuple:
"""
Returns:
history β€” updated chat history
clear_input β€” empty string to clear the textbox
fq1, fq2, fq3 β€” three follow-up question strings (may be empty)
retrieval_md β€” markdown string for the retrieval details panel
"""
start = time.time()
NO_EXTRAS = ("", "", "", "") # fq1, fq2, fq3, retrieval_md
# ── Before-Agent Guardrail ────────────────────────
is_safe, block_reason = before_agent_guardrail(query)
if not is_safe:
_log(query, time.time()-start, 0, 0, 0, 0, blocked=True)
history.append({"role": "user", "content": query})
history.append({"role": "assistant", "content": block_reason})
return history, "", *NO_EXTRAS
# ── Retrieval ─────────────────────────────────────
docs = hybrid_search(query)
if not docs:
history.append({"role": "user", "content": query})
history.append({"role": "assistant",
"content": "❌ Not found in the knowledge base. "
"Please ask about Kubernetes or Docker."})
return history, "", *NO_EXTRAS
# ── Build context & prompt (with conversation memory) ─
ctx, sources = "", []
for i, d in enumerate(docs, 1):
ctx += f"[{i}] {d.page_content[:900]}\nSource: {d.metadata['url']}\n\n"
sources.append(f"[{i}] β†’ {d.metadata['url']}")
prompt = build_prompt_with_history(query, history, ctx)
# ── LLM call (cache β†’ retry β†’ fallback) ──────────
raw_answer, was_cached = call_llm(prompt, context_chunks=[d.page_content for d in docs])
cost = log_cost(query, prompt, raw_answer, cached=was_cached)
latency = time.time() - start
# ── After-Agent Guardrail ─────────────────────────
final_answer, needs_review, groundedness = after_agent_guardrail(raw_answer, docs)
cache_tag = " ⚑ _Served from cache_" if was_cached else ""
final_answer += "\n\n---\n**Sources:**\n" + "\n".join(sources) + cache_tag
if needs_review:
final_answer += "\n\n> ℹ️ A human expert should verify this answer."
# ── Feature 1: Follow-up questions ───────────────
followups = generate_followup_questions(query, raw_answer)
while len(followups) < 3:
followups.append("")
fq1, fq2, fq3 = followups[0], followups[1], followups[2]
# ── Feature 2: Retrieval details panel ───────────
retrieval_md = build_retrieval_details(docs, raw_answer)
# ── Log metrics ───────────────────────────────────
avg_score = sum(d.metadata.get("rerank_score", 0) for d in docs) / len(docs)
cite_count = len(set(re.findall(r"\[(\d+)\]", raw_answer)))
_log(query, latency, groundedness, avg_score, cite_count,
cost, tokens=len(raw_answer.split()),
review=needs_review, cached=was_cached)
history.append({"role": "user", "content": query})
history.append({"role": "assistant", "content": final_answer})
return history, "", fq1, fq2, fq3, retrieval_md
def _log(query, latency, ground, avg_score, cite_count, cost,
tokens=0, review=False, cached=False, blocked=False):
METRICS["q"].append(query)
METRICS["lat"].append(round(latency, 2))
METRICS["tok"].append(tokens)
METRICS["g"].append(ground)
METRICS["r"].append(round(avg_score, 3))
METRICS["c"].append(cite_count)
METRICS["t"].append(classify_query(query))
METRICS["review"].append(review)
METRICS["guardrail"].append(blocked)
METRICS["cached"].append(cached)
METRICS["cost"].append(round(cost, 6))
def use_followup(question: str, history: list) -> tuple:
"""Called when user clicks a follow-up suggestion button."""
if not question.strip():
return history, "", "", "", "", ""
return answer_question(question, history)
# ═══════════════════════════════════════════════════════
# ANALYTICS CHARTS
# ═══════════════════════════════════════════════════════
def make_groundedness_chart():
fig, ax = plt.subplots(figsize=(7, 3))
if not METRICS["g"]:
ax.text(0.5, 0.5, "No data yet", ha="center", va="center", transform=ax.transAxes)
return fig
idx = list(range(1, len(METRICS["g"]) + 1))
colors = ["#2ecc71" if g >= 60 else "#f39c12" if g >= 40 else "#e74c3c"
for g in METRICS["g"]]
ax.bar(idx, METRICS["g"], color=colors, width=0.6, edgecolor="none")
ax.axhline(40, color="#e74c3c", linestyle="--", lw=1, label="Review threshold (40%)")
ax.axhline(60, color="#2ecc71", linestyle="--", lw=1, label="Good (60%)")
ax.set_xlabel("Query #"); ax.set_ylabel("Groundedness (%)")
ax.set_title("Groundedness per Query", fontweight="bold")
ax.set_ylim(0, 105); ax.set_xticks(idx); ax.legend(fontsize=8)
ax.spines["top"].set_visible(False); ax.spines["right"].set_visible(False)
fig.tight_layout(); return fig
def make_query_type_chart():
fig, ax = plt.subplots(figsize=(4, 3))
if not METRICS["t"]:
ax.text(0.5, 0.5, "No data yet", ha="center", va="center", transform=ax.transAxes)
return fig
counts = {}
for t in METRICS["t"]:
counts[t] = counts.get(t, 0) + 1
palette = ["#3498db", "#9b59b6", "#1abc9c", "#e67e22"][:len(counts)]
wedges, texts, autotexts = ax.pie(
counts.values(), labels=counts.keys(), autopct="%1.0f%%",
colors=palette, startangle=90,
wedgeprops={"edgecolor": "white", "linewidth": 1.5})
for at in autotexts: at.set_fontsize(9)
ax.set_title("Query Types", fontweight="bold")
fig.tight_layout(); return fig
def make_latency_chart():
fig, ax = plt.subplots(figsize=(7, 3))
if not METRICS["lat"]:
ax.text(0.5, 0.5, "No data yet", ha="center", va="center", transform=ax.transAxes)
return fig
idx = list(range(1, len(METRICS["lat"]) + 1))
colors = ["#f39c12" if c else "#3498db" for c in METRICS["cached"]]
ax.bar(idx, METRICS["lat"], color=colors, width=0.6, edgecolor="none")
ax.set_xlabel("Query #"); ax.set_ylabel("Latency (s)")
ax.set_title("Latency per Query (orange = cache hit)", fontweight="bold")
ax.set_xticks(idx)
ax.spines["top"].set_visible(False); ax.spines["right"].set_visible(False)
fig.tight_layout(); return fig
def make_cost_chart():
fig, ax = plt.subplots(figsize=(7, 3))
if not METRICS["cost"]:
ax.text(0.5, 0.5, "No data yet", ha="center", va="center", transform=ax.transAxes)
return fig
running, total = [], 0
for c in METRICS["cost"]:
total += c; running.append(round(total, 6))
idx = list(range(1, len(running) + 1))
ax.plot(idx, running, marker="o", color="#9b59b6", lw=2, markersize=5)
ax.fill_between(idx, running, alpha=0.15, color="#9b59b6")
ax.set_xlabel("Query #"); ax.set_ylabel("Cumulative cost (USD)")
ax.set_title("Running Cost Tracker", fontweight="bold")
ax.set_xticks(idx)
ax.spines["top"].set_visible(False); ax.spines["right"].set_visible(False)
fig.tight_layout(); return fig
def update_dashboard():
if not METRICS["q"]:
empty = plt.figure()
return [], 0, 0, 0, 0, 0, 0, empty, empty, empty, empty
rows = list(zip(
range(1, len(METRICS["q"]) + 1), METRICS["q"],
METRICS["lat"], METRICS["tok"], METRICS["g"], METRICS["r"],
METRICS["c"], METRICS["t"],
["βœ…" if v else "β€”" for v in METRICS["review"]],
["πŸ›‘οΈ" if v else "β€”" for v in METRICS["guardrail"]],
["⚑" if v else "β€”" for v in METRICS["cached"]],
METRICS["cost"],
))
n = len(METRICS["g"])
return (
rows,
round(sum(METRICS["g"]) / n, 2),
round(sum(METRICS["lat"]) / n, 2),
round(sum(METRICS["tok"]) / n, 2),
round(sum(METRICS["review"]) / n * 100, 1),
round(sum(METRICS["guardrail"]) / n * 100, 1),
round(sum(METRICS["cost"]), 6),
make_groundedness_chart(), make_query_type_chart(),
make_latency_chart(), make_cost_chart(),
)
# ═══════════════════════════════════════════════════════
# UI
# ═══════════════════════════════════════════════════════
with gr.Blocks(title="Kubernetes & Docker RAG Assistant") as app:
gr.Markdown(
"# ☸️ Kubernetes & Docker RAG Assistant\n"
"> Hybrid RAG Β· Layered Guardrails Β· Conversation Memory Β· "
"Follow-up Suggestions Β· Retrieval Transparency"
)
# ── Tab 1: Chat ───────────────────────────────────
with gr.Tab("πŸ’¬ Chat"):
chat = gr.Chatbot(height=460)
user_in = gr.Textbox(
label="Ask about Kubernetes or Docker",
placeholder="e.g. How do I set resource limits for a Pod?",
)
with gr.Row():
submit_btn = gr.Button("Send", variant="primary")
clear_btn = gr.Button("Clear conversation")
# ── Feature 1: Follow-up question buttons ─────
gr.Markdown("**πŸ’‘ Suggested follow-ups** β€” click to ask:")
with gr.Row():
fq1_btn = gr.Button("", visible=True, size="sm")
fq2_btn = gr.Button("", visible=True, size="sm")
fq3_btn = gr.Button("", visible=True, size="sm")
# ── Feature 2: Retrieval details accordion ────
with gr.Accordion("πŸ” Why this answer? (Retrieval Details)", open=False):
retrieval_panel = gr.Markdown(
"_Ask a question to see which documentation chunks were retrieved, "
"their reranker scores, and which ones were cited._"
)
# Wire up main send
all_outputs = [chat, user_in, fq1_btn, fq2_btn, fq3_btn, retrieval_panel]
user_in.submit(answer_question, [user_in, chat], all_outputs)
submit_btn.click(answer_question, [user_in, chat], all_outputs)
clear_btn.click(
lambda: ([], "", "", "", "", "_Ask a question to see retrieval details._"),
None, all_outputs
)
# Wire up follow-up buttons β€” clicking one runs it as a new query
fq1_btn.click(use_followup, [fq1_btn, chat], all_outputs)
fq2_btn.click(use_followup, [fq2_btn, chat], all_outputs)
fq3_btn.click(use_followup, [fq3_btn, chat], all_outputs)
# ── Tab 2: Guardrails ─────────────────────────────
with gr.Tab("πŸ›‘οΈ Guardrails"):
gr.Markdown("""
### Guardrails Architecture
**Before-Agent (input filtering)**
| Layer | Type | What it checks |
|---|---|---|
| Rate Limiting | Deterministic | Max 10 req / min per user |
| PII Detection | Deterministic | Email, phone, SSN, credit card, IP |
| Prompt Injection | Deterministic | Regex patterns on known attack phrases |
| Safety Classifier | **Model-based** | LLM classifies Safe / Unsafe with reason |
**After-Agent (output safety)**
| Layer | Type | What it checks |
|---|---|---|
| PII Redaction | Deterministic | Scrubs leaked PII from LLM output |
| Citation Verification | Deterministic | Checks [N] refs exist and are valid |
| Groundedness Score | Deterministic | Citation ratio Γ— reranker score β€” not LLM self-report |
| Confidence Threshold | Deterministic | Flags for human review if score < 0.35 or ground < 40% |
**Human-in-the-Loop**
When confidence is low the assistant shows a _Human Review Recommended_ banner.
The reviewer sees groundedness %, reranker scores, and source URLs in the Retrieval Details panel.
""")
# ── Tab 3: Analytics ──────────────────────────────
with gr.Tab("πŸ“Š Analytics"):
gr.Markdown("### Query Analytics Dashboard")
refresh_btn = gr.Button("πŸ”„ Refresh Dashboard")
with gr.Row():
avgG_box = gr.Number(label="Avg Groundedness (%)")
avgL_box = gr.Number(label="Avg Latency (s)")
avgT_box = gr.Number(label="Avg Tokens")
pctReview_box = gr.Number(label="% Human Review")
pctGuardrail_box = gr.Number(label="% Blocked")
totalCost_box = gr.Number(label="Total Cost (USD)")
with gr.Row():
ground_chart = gr.Plot(label="Groundedness over time")
latency_chart = gr.Plot(label="Latency (orange = cache hit)")
with gr.Row():
type_chart = gr.Plot(label="Query types")
cost_chart = gr.Plot(label="Running cost tracker")
gr.Markdown("#### Raw query log")
table = gr.DataFrame(
headers=["ID", "Query", "Latency (s)", "Tokens", "Groundedness (%)",
"Rerank Score", "Citations", "Type",
"Human Review", "Guardrail", "Cached", "Cost (USD)"],
interactive=False,
)
refresh_btn.click(
update_dashboard, None,
[table, avgG_box, avgL_box, avgT_box,
pctReview_box, pctGuardrail_box, totalCost_box,
ground_chart, type_chart, latency_chart, cost_chart],
)
app.launch()