Vineetiitg commited on
Commit
fcd33ac
·
1 Parent(s): d816f3a

feat(ui): revamp frontend with glassmorphism styling, role-based observability tabs, and live chat termination

Browse files
Files changed (2) hide show
  1. ui/app.py +322 -41
  2. ui/styles.css +206 -0
ui/app.py CHANGED
@@ -3,9 +3,26 @@ import time
3
  from pathlib import Path
4
  import requests
5
  import streamlit as st
 
6
 
7
- st.set_page_config(page_title="Support Docs Copilot", page_icon="SD", layout="wide")
8
- st.title("Support Docs Copilot")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  BACKEND_BASE_URL = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8000")
11
  BACKEND_STREAM_URL = os.getenv("BACKEND_URL", f"{BACKEND_BASE_URL}/chat/stream")
@@ -19,6 +36,8 @@ if "role" not in st.session_state:
19
  st.session_state.role = ""
20
  if "username" not in st.session_state:
21
  st.session_state.username = ""
 
 
22
 
23
 
24
  def headers() -> dict:
@@ -41,18 +60,21 @@ def post_json(path: str, payload: dict | None = None) -> dict:
41
 
42
  def poll_job_status(job_id: str, status_text: str = "Processing in background..."):
43
  with st.status(status_text, expanded=True) as status:
44
- st.write("Job enqueued in Redis...")
 
45
  for _ in range(120):
46
  try:
47
  res = get_json(f"/tasks/status/{job_id}")
48
  job_status = res.get("status", "unknown")
49
- st.write(f"Status: **{job_status}**")
50
  if err_msg := res.get("error"):
51
  st.error(f"Error details: {err_msg}")
52
  if job_status in ("complete", "success"):
 
53
  status.update(label="Job Completed Successfully!", state="complete", expanded=False)
54
  return res.get("result")
55
  elif job_status in ("not_found", "error", "failed", "error_try_again"):
 
56
  status.update(label=f"Job Finished ({job_status})", state="complete" if job_status == "complete" else "error", expanded=True)
57
  return res.get("result")
58
  except Exception:
@@ -64,12 +86,24 @@ def poll_job_status(job_id: str, status_text: str = "Processing in background...
64
 
65
  # Authentication Sidebar
66
  with st.sidebar:
67
- st.subheader("🔐 Authentication")
68
  if st.session_state.token and st.session_state.role:
69
  if st.session_state.role == "admin":
70
- st.success(f"Logged in as: **{st.session_state.username or 'Admin'}**\n\nRole: **👑 Administrator**")
 
 
 
 
 
 
71
  else:
72
- st.info(f"Logged in as: **{st.session_state.username or 'User'}**\n\nRole: **👤 User**")
 
 
 
 
 
 
73
  if st.button("🚪 Logout", use_container_width=True):
74
  st.session_state.token = ""
75
  st.session_state.role = ""
@@ -77,8 +111,8 @@ with st.sidebar:
77
  st.rerun()
78
  else:
79
  st.write("Login to access role-specific UI features.")
80
- username_input = st.text_input("Username", key="sb_user")
81
- password_input = st.text_input("Password", type="password", key="sb_pass")
82
  if st.button("��� Login", use_container_width=True, type="primary"):
83
  try:
84
  response = requests.post(
@@ -98,22 +132,87 @@ with st.sidebar:
98
  st.error(f"Login request failed: {exc}")
99
 
100
  st.divider()
101
- st.caption(f"Backend base URL: {BACKEND_BASE_URL}")
102
  try:
103
  ready_data = get_json("/ready")
104
  if ready_data.get("ready"):
105
- st.caption("🟢 Backend System Online")
106
  else:
107
- st.caption("🟡 Backend Degraded")
108
  except Exception:
109
- st.caption("🔴 Backend Offline")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
 
112
  # Tab Renderers
113
  def render_chat_tab():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  for message in st.session_state.messages:
115
  with st.chat_message(message["role"]):
116
  st.markdown(message["content"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  if user_query := st.chat_input("Ask a support question..."):
119
  with st.chat_message("user"):
@@ -124,7 +223,7 @@ def render_chat_tab():
124
  try:
125
  response = requests.post(
126
  f"{BACKEND_BASE_URL}/chat/stream",
127
- json={"query": user_query, "chat_history": st.session_state.messages[:-1]},
128
  headers=headers(),
129
  stream=True,
130
  timeout=120,
@@ -142,35 +241,82 @@ def render_chat_tab():
142
  break
143
  placeholder.markdown(full_answer + "▌")
144
  placeholder.markdown(full_answer)
145
- st.session_state.messages.append({"role": "assistant", "content": full_answer})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  except requests.RequestException as exc:
147
  st.error(f"Chat request failed: {exc}")
148
 
149
 
150
  def render_documents_tab():
151
- st.subheader("Indexed Documents")
152
- if st.button("Refresh list", key="ref_docs"):
153
- st.rerun()
 
 
 
 
 
154
  try:
155
  data = get_json("/documents")
156
  documents = data.get("documents", [])
157
  if not documents:
158
- st.info("No indexed documents found.")
159
  else:
160
  for doc in documents:
161
- st.write(f"**{doc.get('source')}** | id={doc.get('doc_id')} | chunks={doc.get('chunk_count')}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  except requests.RequestException as exc:
163
  st.error(f"Failed to load documents: {exc}")
164
 
165
 
166
  def render_admin_portal_tab():
167
- st.subheader("Document Ingestion & Management")
 
 
 
 
168
  uploaded_files = st.file_uploader(
169
- "Upload support docs",
170
  type=["txt", "md", "pdf", "docx", "html", "htm"],
171
  accept_multiple_files=True,
172
  )
173
- if uploaded_files and st.button("Save uploaded files", type="primary"):
174
  try:
175
  files = [("files", (file.name, file.getvalue(), file.type or "application/octet-stream")) for file in uploaded_files]
176
  response = requests.post(
@@ -180,35 +326,79 @@ def render_admin_portal_tab():
180
  timeout=120,
181
  )
182
  response.raise_for_status()
183
- st.success(f"Uploaded {len(uploaded_files)} file(s) to backend.")
184
  st.json(response.json())
185
  except requests.RequestException as exc:
186
  st.error(f"Upload failed: {exc}")
 
187
 
188
- st.divider()
 
189
  col1, col2 = st.columns(2)
190
  with col1:
191
- force = st.checkbox("Force recreate index")
192
- if st.button("Run ingestion", use_container_width=True):
193
  try:
194
  res = post_json("/admin/ingest", {"data_dir": DATA_DIR, "force": force})
195
  st.json(res)
196
  if job_id := res.get("job_id"):
197
- poll_job_status(job_id, "Ingesting documents via Arq Worker...")
 
 
198
  except requests.RequestException as exc:
199
  st.error(f"Ingestion failed: {exc}")
200
  with col2:
201
- if st.button("Reset index", use_container_width=True):
 
202
  try:
203
  st.json(post_json("/admin/reset"))
 
204
  except requests.RequestException as exc:
205
- st.error(f"Reset failed: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
 
208
  def render_evaluation_tab():
209
- st.subheader("Automated Quality Assessment (RAGAS)")
210
  st.write("Evaluate how accurately and faithfully the copilot answers support questions using the RAGAS framework.")
211
 
 
212
  if st.button("🚀 Run RAG Evaluation Now", type="primary"):
213
  try:
214
  res = post_json("/admin/eval")
@@ -218,34 +408,115 @@ def render_evaluation_tab():
218
  st.success("Evaluation task dispatched!")
219
  except requests.RequestException as exc:
220
  st.error(f"Evaluation failed: {exc}. Ensure you have remaining OpenRouter credits/limits.")
 
221
 
222
  st.divider()
223
- st.subheader("Latest Evaluation Report")
224
  try:
225
  res = get_json("/admin/eval")
226
- st.markdown(res.get("report", "No evaluation report available."))
 
227
  except requests.RequestException:
228
- st.info("No evaluation report available yet. Click the button above to run your first evaluation!")
229
 
230
 
231
  def render_langsmith_tab():
232
- st.subheader("Observability & Tracing (LangSmith)")
233
  st.write("Monitor RAG agent steps, prompt tokens, and latency in real-time by adding these variables to your `.env`:")
234
  st.code("LANGCHAIN_TRACING_V2=true\nLANGCHAIN_API_KEY=your_langsmith_api_key\nLANGCHAIN_PROJECT=\"Support Docs Copilot\"", language="env")
235
 
236
  st.divider()
237
- st.subheader("System Readiness Diagnostics")
238
- st.caption(f"Backend base URL: {BACKEND_BASE_URL}")
239
  try:
240
- st.json(get_json("/ready"))
 
 
 
 
 
 
 
 
241
  except requests.RequestException as exc:
242
  st.error(f"Readiness check failed: {exc}")
243
 
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  # Render Role-Specific UI Layouts
246
  if st.session_state.role == "admin":
247
- chat_tab, docs_tab, admin_tab, eval_tab, langsmith_tab = st.tabs([
248
  "💬 Chat Copilot",
 
249
  "📚 Documents",
250
  "🛠️ Admin Portal",
251
  "📊 RAGAS Evaluation",
@@ -253,6 +524,8 @@ if st.session_state.role == "admin":
253
  ])
254
  with chat_tab:
255
  render_chat_tab()
 
 
256
  with docs_tab:
257
  render_documents_tab()
258
  with admin_tab:
@@ -272,9 +545,17 @@ else:
272
  with docs_tab:
273
  render_documents_tab()
274
  with status_tab:
275
- st.subheader("System Status")
276
  try:
277
- st.json(get_json("/ready"))
 
 
 
 
 
 
 
 
278
  except requests.RequestException as exc:
279
  st.error(f"Readiness check failed: {exc}")
280
  st.divider()
 
3
  from pathlib import Path
4
  import requests
5
  import streamlit as st
6
+ import uuid
7
 
8
+ st.set_page_config(page_title="Support Docs Copilot", page_icon="🚀", layout="wide")
9
+
10
+ def load_css():
11
+ css_path = Path(__file__).parent / "styles.css"
12
+ if css_path.exists():
13
+ st.markdown(f"<style>{css_path.read_text(encoding='utf-8')}</style>", unsafe_allow_html=True)
14
+
15
+ load_css()
16
+
17
+ # Header & Banner
18
+ st.markdown("""
19
+ <div style="padding: 0.5rem 0; margin-bottom: 1rem;">
20
+ <h1 style="font-size: 2.6rem; margin: 0;">🚀 Support Docs Copilot</h1>
21
+ <p style="color: #94a3b8; font-size: 1.05rem; margin-top: 0.2rem;">
22
+ Next-Gen Agentic RAG Assistant powered by LangGraph, Speculative Retrieval & Cohere Reranking
23
+ </p>
24
+ </div>
25
+ """, unsafe_allow_html=True)
26
 
27
  BACKEND_BASE_URL = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8000")
28
  BACKEND_STREAM_URL = os.getenv("BACKEND_URL", f"{BACKEND_BASE_URL}/chat/stream")
 
36
  st.session_state.role = ""
37
  if "username" not in st.session_state:
38
  st.session_state.username = ""
39
+ if "session_id" not in st.session_state:
40
+ st.session_state.session_id = str(uuid.uuid4())
41
 
42
 
43
  def headers() -> dict:
 
60
 
61
  def poll_job_status(job_id: str, status_text: str = "Processing in background..."):
62
  with st.status(status_text, expanded=True) as status:
63
+ st.write("Job enqueued in Redis worker queue...")
64
+ status_placeholder = st.empty()
65
  for _ in range(120):
66
  try:
67
  res = get_json(f"/tasks/status/{job_id}")
68
  job_status = res.get("status", "unknown")
69
+ status_placeholder.markdown(f"Status: **{job_status}**")
70
  if err_msg := res.get("error"):
71
  st.error(f"Error details: {err_msg}")
72
  if job_status in ("complete", "success"):
73
+ status_placeholder.markdown("Status: **Complete** ✅")
74
  status.update(label="Job Completed Successfully!", state="complete", expanded=False)
75
  return res.get("result")
76
  elif job_status in ("not_found", "error", "failed", "error_try_again"):
77
+ status_placeholder.markdown(f"Status: **{job_status}** ❌")
78
  status.update(label=f"Job Finished ({job_status})", state="complete" if job_status == "complete" else "error", expanded=True)
79
  return res.get("result")
80
  except Exception:
 
86
 
87
  # Authentication Sidebar
88
  with st.sidebar:
89
+ st.markdown("### 🔐 Authentication")
90
  if st.session_state.token and st.session_state.role:
91
  if st.session_state.role == "admin":
92
+ st.markdown(f"""
93
+ <div class="glass-card" style="padding: 1rem; border-left: 4px solid #a855f7;">
94
+ <p style="margin: 0; font-size: 0.9rem; color: #cbd5e1;">Logged in as:</p>
95
+ <p style="margin: 0; font-size: 1.1rem; font-weight: 600; color: #f8fafc;">👑 {st.session_state.username or 'Admin'}</p>
96
+ <span class="status-badge badge-purple" style="margin-top: 0.5rem;">Administrator</span>
97
+ </div>
98
+ """, unsafe_allow_html=True)
99
  else:
100
+ st.markdown(f"""
101
+ <div class="glass-card" style="padding: 1rem; border-left: 4px solid #60a5fa;">
102
+ <p style="margin: 0; font-size: 0.9rem; color: #cbd5e1;">Logged in as:</p>
103
+ <p style="margin: 0; font-size: 1.1rem; font-weight: 600; color: #f8fafc;">👤 {st.session_state.username or 'User'}</p>
104
+ <span class="status-badge badge-blue" style="margin-top: 0.5rem;">User</span>
105
+ </div>
106
+ """, unsafe_allow_html=True)
107
  if st.button("🚪 Logout", use_container_width=True):
108
  st.session_state.token = ""
109
  st.session_state.role = ""
 
111
  st.rerun()
112
  else:
113
  st.write("Login to access role-specific UI features.")
114
+ username_input = st.text_input("Username", key="sb_user", placeholder="admin or user")
115
+ password_input = st.text_input("Password", type="password", key="sb_pass", placeholder="••••••••")
116
  if st.button("��� Login", use_container_width=True, type="primary"):
117
  try:
118
  response = requests.post(
 
132
  st.error(f"Login request failed: {exc}")
133
 
134
  st.divider()
135
+ st.caption(f"🔗 Backend API: `{BACKEND_BASE_URL}`")
136
  try:
137
  ready_data = get_json("/ready")
138
  if ready_data.get("ready"):
139
+ st.markdown('<span class="status-badge badge-green">🟢 System Online</span>', unsafe_allow_html=True)
140
  else:
141
+ st.markdown('<span class="status-badge badge-yellow">🟡 System Degraded</span>', unsafe_allow_html=True)
142
  except Exception:
143
+ st.markdown('<span class="status-badge" style="background: rgba(239,68,68,0.2); color: #f87171; border: 1px solid #ef4444;">🔴 Backend Offline</span>', unsafe_allow_html=True)
144
+
145
+ st.divider()
146
+ st.markdown("### 💬 Recent Chats")
147
+ if st.button("➕ New Support Topic", use_container_width=True, type="primary", key="new_chat_btn"):
148
+ st.session_state.session_id = str(uuid.uuid4())
149
+ st.session_state.messages = []
150
+ st.rerun()
151
+
152
+ if not st.session_state.token:
153
+ st.info("🔒 **Not logged in.** Please login above to access and resume your saved recent chat history.")
154
+ else:
155
+ if st.session_state.role == "admin":
156
+ st.caption(f"Showing saved sessions for **👑 Admin ({st.session_state.username})**")
157
+ else:
158
+ st.caption(f"Showing saved sessions for **👤 {st.session_state.username}**")
159
+
160
+ try:
161
+ sessions_res = get_json("/api/v1/sessions")
162
+ sessions_list = sessions_res.get("sessions", [])
163
+ if not sessions_list:
164
+ st.caption("No recent sessions found for your account.")
165
+ else:
166
+ for s in sessions_list[:10]:
167
+ sid = s.get("session_id", "default")
168
+ preview = s.get("last_preview", sid[:8] + "...")
169
+ btn_label = f"💬 {preview}" if sid != st.session_state.get("session_id") else f"🟢 {preview}"
170
+ if st.button(btn_label, key=f"sess_{sid}", use_container_width=True):
171
+ st.session_state.session_id = sid
172
+ msg_res = get_json(f"/api/v1/sessions/{sid}/messages")
173
+ st.session_state.messages = msg_res.get("messages", [])
174
+ st.rerun()
175
+ except Exception:
176
+ st.caption("Could not load sessions.")
177
 
178
 
179
  # Tab Renderers
180
  def render_chat_tab():
181
+ col_stop, col_info = st.columns([1, 4])
182
+ with col_stop:
183
+ if st.button("🛑 Stop Generation", key="term_btn_top", use_container_width=True):
184
+ if sid := st.session_state.get("session_id"):
185
+ try:
186
+ post_json(f"/api/v1/sessions/{sid}/terminate")
187
+ st.toast("🛑 Sent termination signal to active generation!")
188
+ except Exception:
189
+ pass
190
+ with col_info:
191
+ st.caption("💡 Tip: Click **🛑 Stop Generation** anytime during text output to immediately terminate an ongoing chat response.")
192
+
193
+ st.divider()
194
+
195
  for message in st.session_state.messages:
196
  with st.chat_message(message["role"]):
197
  st.markdown(message["content"])
198
+ if message["role"] == "assistant":
199
+ conf = message.get("confidence")
200
+ sources = message.get("sources")
201
+ if conf is not None or sources:
202
+ cols = st.columns([1, 4])
203
+ with cols[0]:
204
+ if conf is not None:
205
+ badge_class = "badge-green" if conf >= 0.8 else ("badge-yellow" if conf >= 0.5 else "badge-purple")
206
+ st.markdown(f'<span class="status-badge {badge_class}">🎯 Conf: {conf*100:.0f}%</span>', unsafe_allow_html=True)
207
+ with cols[1]:
208
+ if sources:
209
+ with st.expander(f"📚 Cited Sources ({len(sources)} documents referenced)"):
210
+ for idx, src in enumerate(sources, 1):
211
+ src_name = src.get("source", src.get("doc_id", "Unknown Document"))
212
+ score = src.get("relevance_score", src.get("similarity_score", 0.0))
213
+ st.markdown(f"**{idx}. {src_name}** `(Relevance Score: {score:.2f})`")
214
+ if snippet := src.get("content_snippet"):
215
+ st.caption(f'"{snippet[:200]}..."')
216
 
217
  if user_query := st.chat_input("Ask a support question..."):
218
  with st.chat_message("user"):
 
223
  try:
224
  response = requests.post(
225
  f"{BACKEND_BASE_URL}/chat/stream",
226
+ json={"query": user_query, "chat_history": st.session_state.messages[:-1], "session_id": st.session_state.get("session_id")},
227
  headers=headers(),
228
  stream=True,
229
  timeout=120,
 
241
  break
242
  placeholder.markdown(full_answer + "▌")
243
  placeholder.markdown(full_answer)
244
+
245
+ # Append locally immediately so output ALWAYS displays on screen
246
+ new_msg = {"role": "assistant", "content": full_answer}
247
+ st.session_state.messages.append(new_msg)
248
+
249
+ # Fetch updated session messages from backend ONLY if backend has more or equal messages (preventing erasure)
250
+ time.sleep(0.35)
251
+ sid = st.session_state.get("session_id")
252
+ if sid:
253
+ try:
254
+ res_msgs = get_json(f"/api/v1/sessions/{sid}/messages")
255
+ if res_msgs and (msgs := res_msgs.get("messages")) and len(msgs) >= len(st.session_state.messages):
256
+ st.session_state.messages = msgs
257
+ except Exception:
258
+ pass
259
+ st.rerun()
260
  except requests.RequestException as exc:
261
  st.error(f"Chat request failed: {exc}")
262
 
263
 
264
  def render_documents_tab():
265
+ st.markdown("### 📚 Indexed Knowledge Base")
266
+ st.write("Manage your RAG vector store documents. You can inspect chunk counts, content hashes, or remove individual files.")
267
+
268
+ col_ref, col_spacer = st.columns([1, 5])
269
+ with col_ref:
270
+ if st.button("🔄 Refresh List", key="ref_docs", use_container_width=True):
271
+ st.rerun()
272
+
273
  try:
274
  data = get_json("/documents")
275
  documents = data.get("documents", [])
276
  if not documents:
277
+ st.info("💡 Knowledge base is currently empty. Login as an Admin and go to **🛠️ Admin Portal** to ingest documents.")
278
  else:
279
  for doc in documents:
280
+ with st.container():
281
+ st.markdown(f"""
282
+ <div class="glass-card" style="padding: 1.2rem; margin-bottom: 0.6rem;">
283
+ <div style="display: flex; justify-content: space-between; align-items: center;">
284
+ <div>
285
+ <h4 style="margin: 0; color: #60a5fa;">📄 {doc.get('source')}</h4>
286
+ <p style="margin: 0.3rem 0 0 0; color: #94a3b8; font-size: 0.85rem;">
287
+ <b>ID:</b> <code>{doc.get('doc_id')}</code> | <b>Chunks:</b> <span class="status-badge badge-blue">{doc.get('chunk_count')} chunks</span> | <b>Hash:</b> <code>{str(doc.get('content_hash'))[:10]}...</code>
288
+ </p>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ """, unsafe_allow_html=True)
293
+ if st.session_state.role == "admin":
294
+ col_del, col_blank = st.columns([1, 5])
295
+ with col_del:
296
+ if st.button("🗑️ Delete File & Embeddings", key=f"del_{doc.get('doc_id')}", use_container_width=True):
297
+ try:
298
+ requests.delete(f"{BACKEND_BASE_URL}/admin/documents/{doc.get('doc_id')}", headers=headers(), timeout=10)
299
+ st.success(f"🗑️ Deleted file '{doc.get('source')}' from disk and removed its embeddings from Qdrant!")
300
+ st.rerun()
301
+ except Exception as exc:
302
+ st.error(f"Delete failed: {exc}")
303
+ st.divider()
304
  except requests.RequestException as exc:
305
  st.error(f"Failed to load documents: {exc}")
306
 
307
 
308
  def render_admin_portal_tab():
309
+ st.markdown("### 🛠️ Document Ingestion & Index Control")
310
+ st.write("Upload new knowledge documents, trigger hybrid vector index rebuilds via Arq worker, or reset the collection.")
311
+
312
+ st.markdown('<div class="glass-card">', unsafe_allow_html=True)
313
+ st.markdown("#### 📤 Upload Documents to Storage")
314
  uploaded_files = st.file_uploader(
315
+ "Select files (.pdf, .docx, .txt, .md, .html)",
316
  type=["txt", "md", "pdf", "docx", "html", "htm"],
317
  accept_multiple_files=True,
318
  )
319
+ if uploaded_files and st.button("💾 Save Uploaded Files to Backend", type="primary"):
320
  try:
321
  files = [("files", (file.name, file.getvalue(), file.type or "application/octet-stream")) for file in uploaded_files]
322
  response = requests.post(
 
326
  timeout=120,
327
  )
328
  response.raise_for_status()
329
+ st.success(f" Successfully saved {len(uploaded_files)} file(s) to backend storage!")
330
  st.json(response.json())
331
  except requests.RequestException as exc:
332
  st.error(f"Upload failed: {exc}")
333
+ st.markdown('</div>', unsafe_allow_html=True)
334
 
335
+ st.markdown('<div class="glass-card">', unsafe_allow_html=True)
336
+ st.markdown("#### ⚙️ Hybrid Vector Indexing")
337
  col1, col2 = st.columns(2)
338
  with col1:
339
+ force = st.checkbox("⚠️ Force recreate index (wipes existing embeddings)")
340
+ if st.button("🚀 Run Document Ingestion", use_container_width=True, type="primary"):
341
  try:
342
  res = post_json("/admin/ingest", {"data_dir": DATA_DIR, "force": force})
343
  st.json(res)
344
  if job_id := res.get("job_id"):
345
+ result = poll_job_status(job_id, "Ingesting & embedding documents via Arq Worker...")
346
+ if result and result.get("status") == "SUCCESS":
347
+ st.success("✅ Ingestion complete! Switch to the 📚 Documents tab or click Refresh list to see your new documents.")
348
  except requests.RequestException as exc:
349
  st.error(f"Ingestion failed: {exc}")
350
  with col2:
351
+ st.write("")
352
+ if st.button("🗑️ Reset Entire Index", use_container_width=True):
353
  try:
354
  st.json(post_json("/admin/reset"))
355
+ st.success("Index reset successfully.")
356
  except requests.RequestException as exc:
357
+ st.markdown('</div>', unsafe_allow_html=True)
358
+
359
+ st.markdown('<div class="glass-card">', unsafe_allow_html=True)
360
+ st.markdown("#### 🗑️ One-Click Post-Ingestion File & Embedding Management")
361
+ st.write("Easily remove uploaded files from storage and wipe their vector embeddings in one click after running ingestion.")
362
+ try:
363
+ docs_res = get_json("/documents")
364
+ ingested_docs = docs_res.get("documents", [])
365
+ if not ingested_docs:
366
+ st.caption("No ingested documents currently found.")
367
+ else:
368
+ selected_to_delete = []
369
+ for doc in ingested_docs:
370
+ col_name, col_btn = st.columns([3, 1])
371
+ with col_name:
372
+ if st.checkbox(f"📄 **{doc.get('source')}** (`{doc.get('chunk_count')} chunks`)", key=f"adm_chk_{doc.get('doc_id')}"):
373
+ selected_to_delete.append(doc)
374
+ with col_btn:
375
+ if st.button("🗑️ Delete (1-Click)", key=f"adm_del_{doc.get('doc_id')}", use_container_width=True):
376
+ try:
377
+ requests.delete(f"{BACKEND_BASE_URL}/admin/documents/{doc.get('doc_id')}", headers=headers(), timeout=10)
378
+ st.success(f"🗑️ Deleted file '{doc.get('source')}' and removed its embeddings!")
379
+ st.rerun()
380
+ except Exception as exc:
381
+ st.error(f"Delete failed: {exc}")
382
+ if selected_to_delete:
383
+ st.write("")
384
+ if st.button(f"🗑️ Delete {len(selected_to_delete)} Selected File(s) & Embeddings in One Click", type="primary", use_container_width=True):
385
+ for d in selected_to_delete:
386
+ try:
387
+ requests.delete(f"{BACKEND_BASE_URL}/admin/documents/{d.get('doc_id')}", headers=headers(), timeout=10)
388
+ except Exception:
389
+ pass
390
+ st.success(f"🗑️ Successfully deleted {len(selected_to_delete)} file(s) and removed their embeddings!")
391
+ st.rerun()
392
+ except Exception as exc:
393
+ st.caption(f"Could not load ingested documents: {exc}")
394
+ st.markdown('</div>', unsafe_allow_html=True)
395
 
396
 
397
  def render_evaluation_tab():
398
+ st.markdown("### 📊 Automated Quality Assessment (RAGAS)")
399
  st.write("Evaluate how accurately and faithfully the copilot answers support questions using the RAGAS framework.")
400
 
401
+ st.markdown('<div class="glass-card">', unsafe_allow_html=True)
402
  if st.button("🚀 Run RAG Evaluation Now", type="primary"):
403
  try:
404
  res = post_json("/admin/eval")
 
408
  st.success("Evaluation task dispatched!")
409
  except requests.RequestException as exc:
410
  st.error(f"Evaluation failed: {exc}. Ensure you have remaining OpenRouter credits/limits.")
411
+ st.markdown('</div>', unsafe_allow_html=True)
412
 
413
  st.divider()
414
+ st.markdown("#### 📑 Latest Evaluation Report")
415
  try:
416
  res = get_json("/admin/eval")
417
+ report_text = res.get("report", "No evaluation report available.")
418
+ st.markdown(f'<div class="glass-card">{report_text}</div>', unsafe_allow_html=True)
419
  except requests.RequestException:
420
+ st.info("💡 No evaluation report available yet. Click the button above to run your first evaluation!")
421
 
422
 
423
  def render_langsmith_tab():
424
+ st.markdown("### 📈 Observability & Tracing (LangSmith)")
425
  st.write("Monitor RAG agent steps, prompt tokens, and latency in real-time by adding these variables to your `.env`:")
426
  st.code("LANGCHAIN_TRACING_V2=true\nLANGCHAIN_API_KEY=your_langsmith_api_key\nLANGCHAIN_PROJECT=\"Support Docs Copilot\"", language="env")
427
 
428
  st.divider()
429
+ st.markdown("#### 🔍 System Readiness Diagnostics")
430
+ st.caption(f"Backend API Base URL: `{BACKEND_BASE_URL}`")
431
  try:
432
+ ready_data = get_json("/ready")
433
+ cols = st.columns(3)
434
+ with cols[0]:
435
+ st.metric("Overall Status", "🟢 Ready" if ready_data.get("ready") else "🟡 Degraded")
436
+ with cols[1]:
437
+ st.metric("Vector Store", "🟢 Online" if ready_data.get("vector_store") else "🔴 Offline")
438
+ with cols[2]:
439
+ st.metric("LLM Provider", "🟢 Connected" if ready_data.get("llm") else "🔴 Offline")
440
+ st.json(ready_data)
441
  except requests.RequestException as exc:
442
  st.error(f"Readiness check failed: {exc}")
443
 
444
 
445
+ def render_observability_dashboard_tab():
446
+ st.markdown("### 👀 Live Session Observability Dashboard")
447
+ st.write("Monitor ongoing user chat threads across the enterprise, inspect RAG source citations, and inject supervisor guidance.")
448
+
449
+ col1, col2 = st.columns([1, 2])
450
+ with col1:
451
+ st.markdown("#### 🧵 Active Enterprise Threads")
452
+ if st.button("🔄 Refresh Live Sessions", key="ref_obs", use_container_width=True):
453
+ st.rerun()
454
+ try:
455
+ res = get_json("/api/v1/admin/sessions")
456
+ all_sess = res.get("sessions", [])
457
+ if not all_sess:
458
+ st.info("No active user sessions found.")
459
+ selected_sess = None
460
+ else:
461
+ # Sort all user sessions by latest timestamp in descending order
462
+ all_sess.sort(key=lambda x: x.get("updated_at", 0), reverse=True)
463
+ options = {}
464
+ for s in all_sess:
465
+ uid = s.get("user_id", "unknown")
466
+ sid = s["session_id"]
467
+ preview = s.get("last_preview", sid[:8] + "...")
468
+ t_val = s.get("updated_at", time.time())
469
+ t_str = time.strftime('%H:%M:%S', time.localtime(t_val)) if t_val else "recently"
470
+ label = f"[{t_str}] 👤 {uid} | {preview} ({sid[:6]})"
471
+ options[label] = (uid, sid)
472
+ selected_label = st.radio("Select Thread (Sorted by Recent Activity):", list(options.keys()), key="obs_radio")
473
+ selected_sess = options[selected_label] if selected_label else None
474
+ except Exception as exc:
475
+ st.error(f"Failed to fetch live sessions: {exc}")
476
+ selected_sess = None
477
+
478
+ with col2:
479
+ st.markdown("#### 🔬 Live Thread Inspection & Intervention")
480
+ if selected_sess:
481
+ target_uid, target_sid = selected_sess
482
+ try:
483
+ thread_res = get_json(f"/api/v1/admin/sessions/{target_uid}/{target_sid}/messages")
484
+ msgs = thread_res.get("messages", [])
485
+ summary = thread_res.get("summary")
486
+
487
+ if summary:
488
+ st.markdown(f'<div class="glass-card" style="border-left: 4px solid #38bdf8;"><b>🧠 Dense Background Memory Summary:</b><br>{summary}</div>', unsafe_allow_html=True)
489
+
490
+ st.markdown(f"**Viewing Session:** `{target_sid}` | **User Account:** `{target_uid}`")
491
+
492
+ with st.container(height=400, border=True):
493
+ for m in msgs:
494
+ role_icon = "👤 User" if m["role"] == "user" else ("👑 Supervisor" if m["role"] == "supervisor" else "🤖 Copilot")
495
+ st.markdown(f"**{role_icon}** ({time.strftime('%H:%M:%S', time.localtime(m.get('timestamp', time.time())))}):")
496
+ st.markdown(m.get("content", ""))
497
+ if sources := m.get("sources"):
498
+ with st.expander(f"📚 Inspect {len(sources)} Cited RAG Sources (Confidence: {m.get('confidence', 0.0)*100:.0f}%)"):
499
+ st.json(sources)
500
+ st.divider()
501
+
502
+ st.markdown("#### 🚨 Inject Supervisor Guidance")
503
+ intervene_msg = st.text_input("Type clarification or correction message for this thread...", key="inv_input")
504
+ if st.button("Inject Message into Thread", type="primary", key="inv_btn"):
505
+ if intervene_msg:
506
+ post_json(f"/api/v1/admin/sessions/{target_uid}/{target_sid}/message", {"message": intervene_msg, "role": "supervisor"})
507
+ st.success("Supervisor intervention injected successfully!")
508
+ st.rerun()
509
+ except Exception as exc:
510
+ st.error(f"Could not load thread details: {exc}")
511
+ else:
512
+ st.caption("Select an active thread from the left column to inspect chat history, verify citations, and intervene.")
513
+
514
+
515
  # Render Role-Specific UI Layouts
516
  if st.session_state.role == "admin":
517
+ chat_tab, obs_tab, docs_tab, admin_tab, eval_tab, langsmith_tab = st.tabs([
518
  "💬 Chat Copilot",
519
+ "👀 Live Observability",
520
  "📚 Documents",
521
  "🛠️ Admin Portal",
522
  "📊 RAGAS Evaluation",
 
524
  ])
525
  with chat_tab:
526
  render_chat_tab()
527
+ with obs_tab:
528
+ render_observability_dashboard_tab()
529
  with docs_tab:
530
  render_documents_tab()
531
  with admin_tab:
 
545
  with docs_tab:
546
  render_documents_tab()
547
  with status_tab:
548
+ st.markdown("### ⚙️ System Status")
549
  try:
550
+ ready_data = get_json("/ready")
551
+ cols = st.columns(3)
552
+ with cols[0]:
553
+ st.metric("Overall Status", "🟢 Ready" if ready_data.get("ready") else "🟡 Degraded")
554
+ with cols[1]:
555
+ st.metric("Vector Store", "🟢 Online" if ready_data.get("vector_store") else "🔴 Offline")
556
+ with cols[2]:
557
+ st.metric("LLM Provider", "🟢 Connected" if ready_data.get("llm") else "🔴 Offline")
558
+ st.json(ready_data)
559
  except requests.RequestException as exc:
560
  st.error(f"Readiness check failed: {exc}")
561
  st.divider()
ui/styles.css ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
2
+
3
+ /* Global Typography & Background */
4
+ html, body, [class*="css"] {
5
+ font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif !important;
6
+ }
7
+
8
+ /* App Main Background with Subtle Ambient Glow */
9
+ .stApp {
10
+ background: radial-gradient(circle at 15% 20%, rgba(99, 102, 241, 0.08) 0%, transparent 40%),
11
+ radial-gradient(circle at 85% 80%, rgba(168, 85, 247, 0.08) 0%, transparent 40%),
12
+ linear-gradient(180deg, #0b0f19 0%, #0f172a 100%) !important;
13
+ color: #f8fafc;
14
+ }
15
+
16
+ /* Glassmorphic Containers and Cards */
17
+ .glass-card {
18
+ background: rgba(30, 41, 59, 0.45);
19
+ backdrop-filter: blur(16px);
20
+ -webkit-backdrop-filter: blur(16px);
21
+ border: 1px solid rgba(255, 255, 255, 0.08);
22
+ border-radius: 16px;
23
+ padding: 1.5rem;
24
+ margin-bottom: 1.2rem;
25
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
26
+ transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
27
+ }
28
+
29
+ .glass-card:hover {
30
+ transform: translateY(-2px);
31
+ border-color: rgba(168, 85, 247, 0.3);
32
+ box-shadow: 0 12px 40px 0 rgba(168, 85, 247, 0.15);
33
+ }
34
+
35
+ /* Header & Title Gradient */
36
+ h1 {
37
+ font-weight: 700 !important;
38
+ background: linear-gradient(135deg, #60a5fa 0%, #c084fc 50%, #f472b6 100%);
39
+ -webkit-background-clip: text !important;
40
+ -webkit-text-fill-color: transparent !important;
41
+ letter-spacing: -0.5px;
42
+ margin-bottom: 0.5rem !important;
43
+ }
44
+
45
+ h2, h3 {
46
+ font-weight: 600 !important;
47
+ color: #e2e8f0 !important;
48
+ letter-spacing: -0.3px;
49
+ }
50
+
51
+ /* Sidebar Styling */
52
+ section[data-testid="stSidebar"] {
53
+ background: rgba(15, 23, 42, 0.8) !important;
54
+ backdrop-filter: blur(20px);
55
+ border-right: 1px solid rgba(255, 255, 255, 0.06);
56
+ }
57
+
58
+ section[data-testid="stSidebar"] hr {
59
+ border-color: rgba(255, 255, 255, 0.08);
60
+ }
61
+
62
+ /* Tab Navigation Styling */
63
+ div[data-baseweb="tab-list"] {
64
+ background: rgba(30, 41, 59, 0.5) !important;
65
+ padding: 0.35rem !important;
66
+ border-radius: 12px !important;
67
+ border: 1px solid rgba(255, 255, 255, 0.06) !important;
68
+ gap: 0.5rem;
69
+ }
70
+
71
+ button[data-baseweb="tab"] {
72
+ font-family: 'Outfit', sans-serif !important;
73
+ font-weight: 500 !important;
74
+ font-size: 0.95rem !important;
75
+ border-radius: 8px !important;
76
+ padding: 0.6rem 1.2rem !important;
77
+ color: #94a3b8 !important;
78
+ transition: all 0.2s ease !important;
79
+ }
80
+
81
+ button[data-baseweb="tab"][aria-selected="true"] {
82
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.25) 0%, rgba(168, 85, 247, 0.25) 100%) !important;
83
+ color: #f8fafc !important;
84
+ border: 1px solid rgba(168, 85, 247, 0.4) !important;
85
+ box-shadow: 0 4px 15px rgba(168, 85, 247, 0.15) !important;
86
+ }
87
+
88
+ /* Button Animations & Gradients */
89
+ button[kind="primary"], .stButton > button[type="primary"], div[data-testid="stFormSubmitButton"] > button {
90
+ background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%) !important;
91
+ color: white !important;
92
+ font-weight: 600 !important;
93
+ border: none !important;
94
+ border-radius: 10px !important;
95
+ padding: 0.6rem 1.4rem !important;
96
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
97
+ box-shadow: 0 4px 15px rgba(168, 85, 247, 0.3) !important;
98
+ }
99
+
100
+ button[kind="primary"]:hover, .stButton > button[type="primary"]:hover {
101
+ transform: translateY(-2px) scale(1.01) !important;
102
+ box-shadow: 0 8px 25px rgba(168, 85, 247, 0.5) !important;
103
+ }
104
+
105
+ button[kind="secondary"], .stButton > button {
106
+ background: rgba(51, 65, 85, 0.4) !important;
107
+ color: #e2e8f0 !important;
108
+ border: 1px solid rgba(255, 255, 255, 0.12) !important;
109
+ border-radius: 10px !important;
110
+ transition: all 0.2s ease !important;
111
+ }
112
+
113
+ button[kind="secondary"]:hover, .stButton > button:hover {
114
+ background: rgba(51, 65, 85, 0.8) !important;
115
+ border-color: rgba(255, 255, 255, 0.25) !important;
116
+ transform: translateY(-1px) !important;
117
+ }
118
+
119
+ /* Chat Message Styling */
120
+ div[data-testid="stChatMessage"] {
121
+ background: rgba(30, 41, 59, 0.35) !important;
122
+ border: 1px solid rgba(255, 255, 255, 0.05) !important;
123
+ border-radius: 16px !important;
124
+ padding: 1.25rem !important;
125
+ margin-bottom: 1rem !important;
126
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
127
+ }
128
+
129
+ div[data-testid="stChatMessage"][data-testid*="user"] {
130
+ background: linear-gradient(135deg, rgba(30, 41, 59, 0.5) 0%, rgba(49, 46, 129, 0.3) 100%) !important;
131
+ border: 1px solid rgba(99, 102, 241, 0.25) !important;
132
+ }
133
+
134
+ /* Chat Input Styling */
135
+ div[data-testid="stChatInput"] > div {
136
+ background: rgba(15, 23, 42, 0.8) !important;
137
+ border: 1px solid rgba(255, 255, 255, 0.15) !important;
138
+ border-radius: 14px !important;
139
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
140
+ }
141
+
142
+ div[data-testid="stChatInput"] > div:focus-within {
143
+ border-color: #a855f7 !important;
144
+ box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.3), 0 8px 30px rgba(0, 0, 0, 0.4) !important;
145
+ }
146
+
147
+ /* Badges & Metrics */
148
+ .status-badge {
149
+ display: inline-block;
150
+ padding: 0.25rem 0.75rem;
151
+ border-radius: 9999px;
152
+ font-size: 0.8rem;
153
+ font-weight: 600;
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.5px;
156
+ }
157
+
158
+ .badge-green {
159
+ background: rgba(16, 185, 129, 0.15);
160
+ color: #34d399;
161
+ border: 1px solid rgba(16, 185, 129, 0.3);
162
+ }
163
+
164
+ .badge-yellow {
165
+ background: rgba(245, 158, 11, 0.15);
166
+ color: #fbbf24;
167
+ border: 1px solid rgba(245, 158, 11, 0.3);
168
+ }
169
+
170
+ .badge-purple {
171
+ background: rgba(168, 85, 247, 0.15);
172
+ color: #c084fc;
173
+ border: 1px solid rgba(168, 85, 247, 0.3);
174
+ }
175
+
176
+ .badge-blue {
177
+ background: rgba(59, 130, 246, 0.15);
178
+ color: #60a5fa;
179
+ border: 1px solid rgba(59, 130, 246, 0.3);
180
+ }
181
+
182
+ /* Source Citation Accordion */
183
+ div[data-testid="stExpander"] {
184
+ background: rgba(15, 23, 42, 0.4) !important;
185
+ border: 1px solid rgba(255, 255, 255, 0.08) !important;
186
+ border-radius: 12px !important;
187
+ overflow: hidden;
188
+ }
189
+
190
+ div[data-testid="stExpander"] summary {
191
+ font-weight: 500 !important;
192
+ color: #cbd5e1 !important;
193
+ }
194
+
195
+ /* Metrics & Stats Box */
196
+ div[data-testid="stMetric"] {
197
+ background: rgba(30, 41, 59, 0.4);
198
+ border: 1px solid rgba(255, 255, 255, 0.08);
199
+ border-radius: 12px;
200
+ padding: 1rem;
201
+ }
202
+
203
+ /* Code blocks & Monospace */
204
+ code, pre {
205
+ font-family: 'JetBrains Mono', monospace !important;
206
+ }