Babu Pallam
UI Fix: light and dark theme has been fixed
adeba40
Raw
History Blame Contribute Delete
72.8 kB
# ============================================================
# FILE: app/streamlit_app.py
# ============================================================
# PURPOSE:
# Clean NotebookLM-inspired RAG chatbot UI for KnowFlow AI.
#
# APP TAGLINE:
# "Ask your documents. Trace every answer to its source."
#
# MAIN FIXES IN THIS VERSION:
# 1. The chat is now the central application area.
# 2. Answers, references, retrieved chunks, and debug details appear inside the chat window.
# 3. Suggested questions appear inside the chat window before the conversation.
# 4. A normal question input is provided inside the chat window using a form.
# 5. The bottom floating Streamlit chat_input is removed because it visually broke the layout.
# 6. The left panel manages sources: upload, rebuild, view, delete.
# 7. The right panel manages settings, API test, logs, and future recommendation placeholder.
# 8. Demo Mode explicitly loads the built-in source when enabled.
# 9. Suggested questions refresh dynamically from active sources only.
# 10. Added full native Dark/Light theme support via CSS variables.
#
# IMPORTANT:
# Replace only this file:
# app/streamlit_app.py
#
# Keep these files as they are:
# app.py
# app/__init__.py
# src/config.py
# src/rag_pipeline.py
# docker-compose.yml
# .streamlit/config.toml
# ============================================================
# ============================================================
# STEP 1: STANDARD LIBRARY IMPORTS
# ============================================================
import hashlib
import json
import random
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
# ============================================================
# STEP 2: PROJECT ROOT SETUP
# ============================================================
# Streamlit may run from:
# - Docker
# - Hugging Face Spaces
# - local terminal
#
# This makes sure Python can import project modules from src/.
# ============================================================
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# ============================================================
# STEP 3: THIRD-PARTY IMPORTS
# ============================================================
import streamlit as st
# ============================================================
# STEP 4: PROJECT IMPORTS
# ============================================================
from src.config import create_required_folders, load_config, validate_config
from src.chunker import build_chunks_from_documents
from src.document_loader import Document, load_single_document
from src.rag_pipeline import RAGPipeline
from src.text_cleaner import clean_text
DEMO_SOURCE_FILE_NAMES = {"company_policy.txt", "company_policy_demo.txt"}
DEMO_DATA_FOLDER = PROJECT_ROOT / "data" / "sample"
RAW_DATA_FOLDER = PROJECT_ROOT / "data" / "raw"
SUPPORTED_SOURCE_EXTENSIONS = {".txt", ".md", ".csv", ".pdf"}
# ============================================================
# STEP 5: STREAMLIT PAGE CONFIGURATION
# ============================================================
st.set_page_config(
page_title="KnowFlow AI",
page_icon="🧠",
layout="wide",
initial_sidebar_state="collapsed",
)
# ============================================================
# STEP 6: CLEAN NOTEBOOK/RAG CHATBOT CSS
# ============================================================
# IMPORTANT UI RULE:
# Do NOT wrap Streamlit widgets inside custom HTML <div> blocks.
# That caused the earlier broken empty panels.
#
# This CSS uses var(--background-color) and var(--text-color) to
# seamlessly support Streamlit's native Light and Dark modes.
# ============================================================
st.markdown(
"""
<style>
/* ========================================================
GLOBAL PAGE STYLE
======================================================== */
html, body, [data-testid="stAppViewContainer"] {
background-color: var(--background-color);
color: var(--text-color);
font-family: "Inter", "Segoe UI", "Google Sans", sans-serif;
min-height: 100vh;
}
/* ========================================================
STREAMLIT APP TOOLBAR (stAppToolbar) MULTI-COLOR FIX
======================================================== */
[data-testid="stAppToolbar"], .stAppToolbar {
background-color: var(--background-color) !important;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
}
[data-testid="stAppToolbar"] button svg {
fill: var(--text-color) !important;
color: var(--text-color) !important;
}
.block-container {
max-width: 1550px;
padding-top: 0.75rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-bottom: 0.75rem;
}
/* ========================================================
TOP HEADER (Sleek Horizontal Toolbar Design)
======================================================== */
.st-key-top_header {
flex: 0 0 auto;
padding: 0.6rem 1.25rem;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 12px;
background-color: var(--secondary-background-color);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
margin-bottom: 0.75rem;
}
.st-key-top_header [data-testid="stHorizontalBlock"] {
align-items: center !important;
}
.st-key-demo_mode_header_control [data-testid="stVerticalBlock"] {
align-items: flex-end !important;
gap: 0rem !important;
justify-content: center !important;
}
.st-key-demo_mode_header_control .stToggle {
opacity: 1 !important;
visibility: visible !important;
display: inline-flex !important;
transform: scale(1.25) !important;
transform-origin: right center !important;
height: 24px !important;
margin: 0 !important;
padding: 0 !important;
}
.st-key-demo_mode_header_control .stToggle:focus,
.st-key-demo_mode_header_control .stToggle *:focus,
.st-key-demo_mode_header_control .stToggle *:active {
outline: none !important;
box-shadow: none !important;
}
.st-key-demo_mode_header_control [data-testid="stWidgetLabel"] p,
.st-key-demo_mode_header_control label p {
color: var(--text-color) !important;
font-size: 0.8rem !important;
font-weight: 600 !important;
margin: 0 !important;
opacity: 1 !important;
white-space: nowrap !important;
}
.top-header-brand {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
flex-shrink: 0;
}
.top-header-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.85rem;
height: 1.85rem;
border-radius: 6px;
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
font-weight: 800;
line-height: 1;
flex-shrink: 0;
border: 1px solid rgba(128, 128, 128, 0.2);
}
.top-header-copy {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.top-header-title {
margin: 0;
color: var(--text-color);
font-size: 1.15rem;
line-height: 1;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
flex-shrink: 0;
}
.top-header-inline-divider {
color: rgba(128, 128, 128, 0.5);
font-size: 0.95rem;
font-weight: 300;
user-select: none;
flex-shrink: 0;
}
.top-header-tagline {
margin: 0;
color: rgba(128, 128, 128, 0.8);
font-size: 0.8rem;
line-height: 1;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-header-metrics {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
flex-wrap: nowrap;
}
.top-header-metric {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 1.85rem;
padding: 0 0.65rem;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 999px;
background: var(--secondary-background-color);
color: var(--text-color);
line-height: 1;
flex-shrink: 0;
}
.top-header-metric-label {
color: rgba(128, 128, 128, 0.8);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.top-header-metric-value {
color: var(--text-color);
font-size: 0.8rem;
font-weight: 700;
}
.top-header-status-dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 999px;
background: rgba(128, 128, 128, 0.6);
}
.top-header-status-dot-ready {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
}
/* ========================================================
PANEL / CARD STYLE
======================================================== */
.panel-caption {
font-size: 0.84rem;
color: rgba(128, 128, 128, 0.8);
line-height: 1.45;
margin-bottom: 0.8rem;
}
.sources-summary-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 1px minmax(0, 1fr);
align-items: stretch;
gap: 0;
padding: 0.3rem 0.15rem;
border-radius: 18px;
background: var(--secondary-background-color);
border: 1px solid rgba(128, 128, 128, 0.2);
}
.sources-summary-stat {
display: flex;
flex-direction: column;
gap: 0.18rem;
padding: 0.55rem 0.85rem;
}
.sources-summary-label {
font-size: 0.69rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(128, 128, 128, 0.8);
}
.sources-summary-value {
font-size: 1.35rem;
line-height: 1.1;
font-weight: 760;
color: var(--text-color);
}
.sources-summary-unit {
font-size: 0.76rem;
font-weight: 700;
color: rgba(128, 128, 128, 0.7);
margin-left: 0.2rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sources-summary-divider {
width: 1px;
align-self: stretch;
background: rgba(128, 128, 128, 0.2);
}
.sources-section-gap {
height: 0.3rem;
}
.sources-list-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.sources-list-title {
font-size: 0.77rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(128, 128, 128, 0.8);
}
.sources-list-count {
font-size: 0.76rem;
color: rgba(128, 128, 128, 0.8);
}
.source-card {
padding: 0.78rem 0.82rem;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 18px;
background: var(--secondary-background-color);
}
.source-card-top {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.26rem;
}
.source-card-main {
min-width: 0;
}
.source-name {
font-size: 0.87rem;
font-weight: 760;
color: var(--text-color);
line-height: 1.25;
white-space: normal;
word-break: normal;
overflow-wrap: normal;
}
.source-path {
font-size: 0.73rem;
color: rgba(128, 128, 128, 0.8);
margin-top: 0.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.55rem;
font-size: 0.74rem;
color: rgba(128, 128, 128, 0.8);
line-height: 1.3;
}
.source-meta-separator {
color: rgba(128, 128, 128, 0.5);
}
.source-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.16rem 0.46rem;
border-radius: 999px;
background: var(--background-color);
color: var(--text-color);
border: 1px solid rgba(128, 128, 128, 0.2);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
line-height: 1.1;
flex: 0 0 auto;
}
.reference-box {
padding: 0.75rem;
border-radius: 14px;
background: var(--secondary-background-color);
border: 1px solid rgba(128, 128, 128, 0.2);
color: var(--text-color);
margin-top: 0.65rem;
font-size: 0.88rem;
}
.future-box {
background: var(--secondary-background-color);
border: 1px dashed rgba(128, 128, 128, 0.4);
border-radius: 16px;
padding: 0.85rem;
color: var(--text-color);
font-size: 0.84rem;
line-height: 1.4;
}
/* ========================================================
STREAMLIT METRICS
======================================================== */
div[data-testid="stMetric"] {
background: var(--secondary-background-color);
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 16px;
padding: 0.75rem 0.85rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.04);
}
div[data-testid="stMetric"] label {
color: rgba(128, 128, 128, 0.8) !important;
font-weight: 650 !important;
}
div[data-testid="stMetric"] [data-testid="stMetricValue"] {
color: var(--text-color) !important;
font-size: 1.2rem !important;
font-weight: 820 !important;
}
/* ========================================================
STREAMLIT BUTTONS
======================================================== */
.stButton > button {
border-radius: 999px;
border: 1px solid rgba(128, 128, 128, 0.3);
background: var(--secondary-background-color);
color: var(--text-color);
font-weight: 700;
transition: all 0.15s ease-in-out;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.04);
}
.stButton > button:hover {
border-color: var(--primary-color);
color: var(--primary-color);
transform: translateY(-1px);
}
/* ========================================================
STREAMLIT UPLOADER / EXPANDERS / CHAT
======================================================== */
[data-testid="stFileUploader"] {
background: var(--secondary-background-color);
border: 1px dashed rgba(128, 128, 128, 0.4);
border-radius: 18px;
padding: 0.75rem;
}
[data-testid="stExpander"] {
background: var(--secondary-background-color);
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 16px;
overflow: hidden;
}
[data-testid="stExpander"] summary {
color: var(--text-color);
font-weight: 750;
}
input, select {
border-radius: 14px !important;
}
.stSelectbox div[data-baseweb="select"] > div {
border-radius: 14px;
}
code {
border-radius: 8px;
}
/* ========================================================
AVOID INVISIBLE SELECTED TEXT / LOW CONTRAST
======================================================== */
::selection {
background: rgba(128, 128, 128, 0.3) !important;
color: var(--text-color) !important;
}
/* ========================================================
CONVERSATION WORKSPACE
======================================================== */
.st-key-chat_workspace {
border-radius: 26px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--background-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 0.35rem 0.4rem 0.4rem 0.4rem;
}
.st-key-sources_panel [data-testid="stVerticalBlockBorderWrapper"] {
border-radius: 24px;
border: 1px solid rgba(128, 128, 128, 0.2) !important;
background: var(--background-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 0.25rem 0.25rem 0.35rem 0.25rem;
}
.st-key-chat_history {
border-radius: 20px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--secondary-background-color);
padding: 0.35rem 0.45rem 0.5rem 0.45rem;
}
.st-key-chat_history [data-testid="stVerticalBlock"] {
gap: 0.5rem;
padding-right: 0.18rem;
padding-bottom: 1rem;
}
.st-key-chat_history [data-testid="stVerticalBlock"]::-webkit-scrollbar {
width: 10px;
}
.st-key-chat_history [data-testid="stVerticalBlock"]::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.st-key-chat_welcome_state {
padding: 0.15rem 0.1rem 0.75rem;
}
.st-key-chat_welcome_state [data-testid="stVerticalBlock"] {
gap: 0.7rem;
}
.demo-awareness-note {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 18px;
background: var(--secondary-background-color);
padding: 0.78rem 0.9rem;
margin: 0.25rem 0 0.75rem 0;
}
.demo-awareness-note strong {
display: block;
color: var(--text-color);
font-size: 0.86rem;
margin-bottom: 0.2rem;
}
.demo-awareness-note span {
color: rgba(128, 128, 128, 0.8);
font-size: 0.82rem;
line-height: 1.4;
}
.suggested-prompts-heading {
font-size: 0.78rem;
font-weight: 760;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(128, 128, 128, 0.8);
margin-bottom: 0.15rem;
}
.suggested-prompts-caption {
font-size: 0.82rem;
color: rgba(128, 128, 128, 0.7);
line-height: 1.4;
margin-bottom: 0.15rem;
}
.st-key-suggested_questions_scroller {
border-radius: 18px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--secondary-background-color);
padding: 0.7rem 0.55rem 0.6rem 0.55rem;
}
.st-key-suggested_questions_scroller [data-testid="stVerticalBlock"] {
gap: 0.5rem;
padding-right: 0.16rem;
padding-bottom: 0.35rem;
}
.st-key-suggested_questions_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar {
width: 10px;
}
.st-key-suggested_questions_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.st-key-suggested_questions_scroller .stButton > button {
min-height: 2.65rem;
border-radius: 16px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--background-color);
color: var(--text-color);
box-shadow: none;
font-size: 0.82rem;
line-height: 1.25;
white-space: normal;
padding: 0.45rem 0.7rem;
}
.st-key-suggested_questions_scroller .stButton > button:hover {
background: var(--secondary-background-color);
border-color: var(--primary-color);
color: var(--primary-color);
transform: none;
}
.st-key-chat_input_shelf {
margin-top: 0;
padding: 0;
background: transparent;
}
.st-key-chat_input_shelf [data-testid="stVerticalBlock"] {
gap: 0;
}
.st-key-sources_list_scroller {
border-radius: 20px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--secondary-background-color);
padding: 0.75rem 0.55rem 0.55rem 0.55rem;
}
.st-key-guide_tools_scroller {
border-radius: 18px;
border: 1px solid rgba(128, 128, 128, 0.2);
background: var(--secondary-background-color);
padding: 0.2rem 0.35rem 0.35rem 0.35rem;
}
.st-key-sources_list_scroller [data-testid="stVerticalBlock"] {
gap: 0.45rem;
padding-right: 0.14rem;
padding-bottom: 0.4rem;
}
.st-key-guide_tools_scroller [data-testid="stVerticalBlock"] {
gap: 0.45rem;
padding-right: 0.14rem;
padding-bottom: 0.45rem;
}
.st-key-sources_list_scroller .stTextInput > div > div {
background: var(--background-color);
border-radius: 14px;
}
.st-key-sources_list_scroller .stButton > button {
min-height: 2rem;
border-radius: 999px;
border: 1px solid rgba(128, 128, 128, 0.3);
background: var(--background-color);
box-shadow: none;
color: var(--text-color);
font-size: 0.76rem;
font-weight: 700;
padding: 0.1rem 0.85rem;
}
.st-key-sources_list_scroller .stButton > button:hover {
background: var(--secondary-background-color);
border-color: rgba(255, 0, 0, 0.6);
color: rgba(255, 0, 0, 0.8);
transform: none;
}
.st-key-sources_list_scroller .stButton {
margin-top: -0.18rem;
margin-bottom: 0.5rem;
}
.st-key-sources_list_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar {
width: 10px;
}
.st-key-sources_list_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar-thumb,
.st-key-guide_tools_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.st-key-guide_tools_scroller [data-testid="stVerticalBlock"]::-webkit-scrollbar {
width: 10px;
}
[data-testid="stChatMessage"] {
background: transparent !important;
border: 0 !important;
border-radius: 0 !important;
padding: 0.15rem 0 !important;
margin-bottom: 0.9rem !important;
box-shadow: none !important;
}
[data-testid="stChatMessage"] p,
[data-testid="stChatMessage"] li,
[data-testid="stChatMessage"] ul,
[data-testid="stChatMessage"] ol,
[data-testid="stChatMessage"] span,
[data-testid="stChatMessage"] div,
[data-testid="stChatMessage"] strong,
[data-testid="stChatMessage"] em {
color: var(--text-color) !important;
}
[data-testid="stChatMessage"] h1,
[data-testid="stChatMessage"] h2,
[data-testid="stChatMessage"] h3,
[data-testid="stChatMessage"] h4,
[data-testid="stChatMessage"] h5,
[data-testid="stChatMessage"] h6 {
color: var(--text-color) !important;
}
[data-testid="stChatMessage"] blockquote {
color: var(--text-color) !important;
background: var(--secondary-background-color) !important;
border-left: 3px solid rgba(128, 128, 128, 0.5) !important;
padding: 0.65rem 0.85rem !important;
border-radius: 10px !important;
margin: 0.85rem 0 !important;
}
[data-testid="stChatMessage"] blockquote p {
color: var(--text-color) !important;
}
[data-testid="stChatMessage"] code {
background: var(--secondary-background-color) !important;
color: var(--primary-color) !important;
border: 1px solid rgba(128, 128, 128, 0.2) !important;
padding: 0.12rem 0.35rem !important;
border-radius: 7px !important;
font-weight: 650 !important;
}
[data-testid="stChatMessage"] a {
color: var(--primary-color) !important;
font-weight: 650 !important;
}
[data-testid="stChatMessage"] [data-testid="stCaptionContainer"] p {
color: rgba(128, 128, 128, 0.8) !important;
}
.st-key-chat_input_shelf [data-testid="stChatInput"] {
position: static !important;
inset: auto !important;
z-index: auto !important;
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
}
</style>
""",
unsafe_allow_html=True,
)
# ============================================================
# STEP 7: SESSION STATE INITIALIZATION
# ============================================================
def initialize_session_state() -> None:
"""
Initialize all session-state keys used by the app.
Streamlit reruns the full script after interactions. Missing keys
can cause deployment crashes, especially on Hugging Face Spaces.
"""
default_values = {
"chat_history": [],
"last_result": None,
"last_rebuild_result": None,
"api_test_result": None,
"auto_rebuild_error": None,
"knowledge_base_ready": False,
"upload_status_message": None,
"delete_status_message": None,
"last_uploaded_files": [],
"knowledge_base_signature": "",
"recommended_questions": [],
"context_recommendation_preview": None,
"show_sources": True,
"show_debug_json": False,
"system_log": [],
"source_search_text": "",
"demo_mode": False,
"demo_mode_previous": None,
"custom_source_files": [],
"active_source_signature": "",
"active_data_folder": "",
}
for key, default_value in default_values.items():
if key not in st.session_state:
st.session_state[key] = default_value
initialize_session_state()
# ============================================================
# STEP 8: SMALL UI HELPERS
# ============================================================
def add_system_log(message: str) -> None:
"""
Add a short system log message to the UI.
"""
timestamp = time.strftime("%H:%M:%S")
st.session_state["system_log"].append(f"{timestamp} | {message}")
st.session_state["system_log"] = st.session_state["system_log"][-10:]
def show_toast(message: str, icon: str = "✅") -> None:
"""
Show toast notification where supported.
"""
try:
st.toast(message, icon=icon)
except Exception:
st.success(message)
# ============================================================
# STEP 9: RAG PIPELINE LOADER
# ============================================================
@st.cache_resource(show_spinner=False)
def get_pipeline() -> RAGPipeline:
"""
Load configuration and initialize the RAG pipeline.
Cached because the embedding model can take time to load.
"""
config = load_config()
validate_config(config)
create_required_folders(config)
return RAGPipeline(config=config)
def load_pipeline_safely() -> Optional[RAGPipeline]:
"""
Load pipeline safely and show useful setup errors.
"""
try:
return get_pipeline()
except Exception as error:
st.error("KnowFlow AI could not start because configuration is incomplete.")
st.code(str(error))
st.info(
"Check Hugging Face Secrets or local .env values. "
"Required values include CLOUD_API_KEY, CLOUD_API_BASE_URL, and CLOUD_CHAT_MODEL."
)
return None
# ============================================================
# STEP 10: KNOWLEDGE BASE FILE HELPERS
# ============================================================
def get_knowledge_base_files(data_folder: Path) -> List[Path]:
"""
Return supported files from the knowledge base folder.
"""
if not data_folder.exists():
return []
return sorted(
[
path
for path in data_folder.rglob("*")
if path.is_file() and path.suffix.lower() in SUPPORTED_SOURCE_EXTENSIONS
]
)
def is_demo_mode_enabled() -> bool:
"""
Read the global Demo Mode switch from session state.
"""
return bool(st.session_state.get("demo_mode", False))
def get_routed_data_folder() -> Path:
"""
Return the active document repository for the current source mode.
"""
return DEMO_DATA_FOLDER if is_demo_mode_enabled() else RAW_DATA_FOLDER
def apply_data_folder_routing(pipeline: RAGPipeline) -> None:
"""
Point the pipeline config at the active source directory.
"""
active_data_folder = get_routed_data_folder()
active_data_folder.mkdir(parents=True, exist_ok=True)
pipeline.config.data_folder = active_data_folder
st.session_state["active_data_folder"] = str(active_data_folder)
def register_custom_source_files(saved_paths: List[Path], data_folder: Path) -> None:
"""
Remember files uploaded by the user for non-demo source indexing.
"""
demo_source_paths = {
(data_folder / file_name).resolve()
for file_name in DEMO_SOURCE_FILE_NAMES
}
existing_paths = {
str(Path(path).resolve())
for path in st.session_state.get("custom_source_files", [])
}
for file_path in saved_paths:
resolved_path = file_path.resolve()
if resolved_path in demo_source_paths:
continue
existing_paths.add(str(resolved_path))
st.session_state["custom_source_files"] = sorted(existing_paths)
def unregister_custom_source_file(file_path: Path) -> None:
"""
Remove a deleted file from the active custom source registry.
"""
resolved_path = str(file_path.resolve())
st.session_state["custom_source_files"] = [
path
for path in st.session_state.get("custom_source_files", [])
if str(Path(path).resolve()) != resolved_path
]
def get_custom_source_files(data_folder: Path) -> List[Path]:
"""
Return only user-uploaded source files that still exist.
"""
resolved_data_folder = data_folder.resolve()
demo_source_paths = {
(data_folder / file_name).resolve()
for file_name in DEMO_SOURCE_FILE_NAMES
}
custom_files = []
for stored_path in st.session_state.get("custom_source_files", []):
try:
candidate_path = Path(stored_path).resolve()
except Exception:
continue
if not candidate_path.exists() or not candidate_path.is_file():
continue
if candidate_path.suffix.lower() not in SUPPORTED_SOURCE_EXTENSIONS:
continue
if candidate_path in demo_source_paths:
continue
if resolved_data_folder not in candidate_path.parents:
continue
custom_files.append(candidate_path)
unique_files = sorted(dict.fromkeys(custom_files), key=lambda path: str(path).lower())
st.session_state["custom_source_files"] = [str(path) for path in unique_files]
return unique_files
def get_active_knowledge_base_files(data_folder: Path) -> List[Path]:
"""
Return the sources that should be visible and indexed right now.
Demo Mode reads only from data/sample. When Demo Mode is off, the app
uses only user-uploaded files from the raw working folder.
"""
if is_demo_mode_enabled():
return get_knowledge_base_files(data_folder)
return get_custom_source_files(data_folder)
def get_file_signature(files: List[Path]) -> str:
"""
Create a fingerprint from file name, size, and modification time.
"""
signature_parts = []
for file_path in files:
try:
stat = file_path.stat()
signature_parts.append(
f"{file_path.resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
)
except FileNotFoundError:
continue
raw_signature = "::".join(signature_parts)
return hashlib.md5(raw_signature.encode("utf-8")).hexdigest()
def get_total_file_size_kb(files: List[Path]) -> float:
"""
Calculate total knowledge-base file size in KB.
"""
total_bytes = 0
for file_path in files:
try:
total_bytes += file_path.stat().st_size
except FileNotFoundError:
continue
return total_bytes / 1024
def save_uploaded_files(uploaded_files: List[Any], data_folder: Path) -> List[Path]:
"""
Save uploaded files into the knowledge-base folder.
"""
saved_paths = []
data_folder.mkdir(parents=True, exist_ok=True)
for uploaded_file in uploaded_files:
safe_file_name = uploaded_file.name.replace("/", "_").replace("\\", "_")
if safe_file_name in DEMO_SOURCE_FILE_NAMES:
safe_file_name = f"uploaded_{safe_file_name}"
output_path = data_folder / safe_file_name
output_path.write_bytes(uploaded_file.getbuffer())
saved_paths.append(output_path)
return saved_paths
def delete_knowledge_base_file(file_path: Path, data_folder: Path) -> None:
"""
Safely delete one source file from data/raw/.
"""
resolved_file = file_path.resolve()
resolved_data_folder = data_folder.resolve()
if resolved_data_folder not in resolved_file.parents:
raise ValueError("Unsafe delete blocked. File is outside the knowledge base folder.")
if resolved_file.exists() and resolved_file.is_file():
resolved_file.unlink()
# ============================================================
# STEP 11: RECOMMENDED QUESTIONS
# ============================================================
def load_base_demo_questions() -> List[str]:
"""
Load base suggested questions from config/sample_questions.json.
"""
sample_questions_path = PROJECT_ROOT / "config" / "sample_questions.json"
fallback_questions = [
"What is the refund policy?",
"How long does standard shipping take?",
"What does the warranty cover?",
"Does the company sell customer data?",
"How many days of annual leave do employees get?",
"How much training support can employees receive?",
"What is the remote work policy?",
"What should employees do if their laptop is damaged?",
"What is the company policy about quantum teleportation?",
]
if not sample_questions_path.exists():
return fallback_questions
try:
data = json.loads(sample_questions_path.read_text(encoding="utf-8"))
return data.get("demo_questions", fallback_questions)
except Exception:
return fallback_questions
def build_dynamic_recommended_questions(files: List[Path]) -> List[str]:
"""
Build suggested questions based on current source files.
"""
if not files:
return []
questions = []
if is_demo_mode_enabled() or any(
file_path.name in DEMO_SOURCE_FILE_NAMES for file_path in files
):
questions.extend(load_base_demo_questions())
for file_path in files:
clean_name = file_path.stem.replace("_", " ").replace("-", " ").strip()
if clean_name:
questions.append(f"Summarize {clean_name}.")
questions.append(f"What are the key points in {clean_name}?")
questions.append(f"What should I know from {clean_name}?")
cleaned_questions = []
seen = set()
for question in questions:
normalized = question.lower().strip()
if normalized not in seen:
cleaned_questions.append(question)
seen.add(normalized)
return cleaned_questions[:10]
def refresh_recommended_questions_if_needed(
data_folder: Path,
force: bool = False,
files: Optional[List[Path]] = None,
) -> None:
"""
Refresh suggested questions when source files change.
"""
if files is None:
files = get_active_knowledge_base_files(data_folder)
current_signature = get_file_signature(files)
needs_refresh = (
force
or current_signature != st.session_state.get("knowledge_base_signature")
or (files and not bool(st.session_state.get("recommended_questions")))
or (not files and bool(st.session_state.get("recommended_questions")))
)
if needs_refresh:
st.session_state["knowledge_base_signature"] = current_signature
st.session_state["recommended_questions"] = build_dynamic_recommended_questions(files)
if files:
add_system_log("Suggested questions refreshed.")
else:
add_system_log("Suggested questions cleared.")
def create_context_recommendation_preview(data_folder: Path) -> Optional[str]:
"""
Placeholder for future random-chunk recommendation generation.
Current behavior:
- Randomly previews text from uploaded text-like files.
Future behavior:
- Pull random indexed chunks from ChromaDB.
- Ask the LLM to generate new recommended questions.
"""
files = get_active_knowledge_base_files(data_folder)
text_like_files = [
path for path in files if path.suffix.lower() in {".txt", ".md", ".csv"}
]
if not text_like_files:
return None
selected_file = random.choice(text_like_files)
try:
text = selected_file.read_text(encoding="utf-8", errors="ignore").strip()
except Exception:
return None
if not text:
return None
start = random.randint(0, max(0, len(text) - 500))
preview = text[start:start + 500].strip()
return (
f"Random context preview from `{selected_file.name}`:\n\n"
f"{preview}"
)
# ============================================================
# STEP 12: KNOWLEDGE BASE REBUILD
# ============================================================
def get_vector_count_safely(pipeline: RAGPipeline) -> int:
"""
Return vector count without letting Chroma errors break the UI.
"""
try:
return pipeline.vector_store.count()
except Exception:
return 0
def load_clean_documents_from_files(
files: List[Path],
project_root: Path,
) -> List[Document]:
"""
Load and clean only the files selected by the current source mode.
"""
documents = []
for file_path in files:
try:
document = load_single_document(
path=file_path,
project_root=project_root,
)
cleaned_text = clean_text(document.text)
if not cleaned_text:
continue
documents.append(
Document(
source=document.source,
text=cleaned_text,
file_type=document.file_type,
character_count=len(cleaned_text),
)
)
except Exception as error:
add_system_log(f"Skipped source {file_path.name}: {error}")
return documents
def rebuild_knowledge_base(
pipeline: RAGPipeline,
files: Optional[List[Path]] = None,
) -> None:
"""
Rebuild vector database from the currently active source files.
"""
active_files = files if files is not None else get_active_knowledge_base_files(
pipeline.config.data_folder
)
with st.spinner("Rebuilding knowledge base..."):
documents = load_clean_documents_from_files(
files=active_files,
project_root=pipeline.config.project_root,
)
chunks = build_chunks_from_documents(
documents=documents,
chunk_size=pipeline.config.chunk_size,
chunk_overlap=pipeline.config.chunk_overlap,
)
pipeline.vector_store.reset_collection()
if chunks:
embeddings = pipeline.embedding_model.embed_texts(
[chunk.text for chunk in chunks]
)
pipeline.vector_store.add_chunks(
chunks=chunks,
embeddings=embeddings,
)
rebuild_result = {
"documents_loaded": len(documents),
"chunks_created": len(chunks),
"vectors_stored": pipeline.vector_store.count(),
"active_sources": [file_path.name for file_path in active_files],
"demo_mode": is_demo_mode_enabled(),
"collection_name": pipeline.config.collection_name,
"embedding_model": pipeline.config.embedding_model_name,
}
st.session_state["last_rebuild_result"] = rebuild_result
st.session_state["knowledge_base_ready"] = rebuild_result.get("vectors_stored", 0) > 0
st.session_state["active_source_signature"] = get_file_signature(active_files)
st.session_state["auto_rebuild_error"] = None
refresh_recommended_questions_if_needed(
data_folder=pipeline.config.data_folder,
force=True,
files=active_files,
)
add_system_log(
f"Knowledge base rebuilt with {rebuild_result.get('vectors_stored', 0)} chunks."
)
def purge_active_rag_state(pipeline: RAGPipeline) -> None:
"""
Clear index, chat history, and generated workspace recommendations.
"""
try:
pipeline.vector_store.reset_collection()
st.session_state["auto_rebuild_error"] = None
except Exception as error:
st.session_state["auto_rebuild_error"] = str(error)
st.session_state["chat_history"] = []
st.session_state["last_result"] = None
st.session_state["last_rebuild_result"] = None
st.session_state["recommended_questions"] = []
st.session_state["context_recommendation_preview"] = None
st.session_state["knowledge_base_signature"] = ""
st.session_state["active_source_signature"] = ""
st.session_state["knowledge_base_ready"] = False
def synchronize_demo_mode_state(pipeline: RAGPipeline) -> None:
"""
Keep source visibility, indexing, and blank-state behavior in sync.
"""
demo_mode_enabled = is_demo_mode_enabled()
previous_demo_mode = st.session_state.get("demo_mode_previous")
active_files = get_active_knowledge_base_files(pipeline.config.data_folder)
active_signature = get_file_signature(active_files)
source_signature_changed = (
active_signature != st.session_state.get("active_source_signature", "")
)
if previous_demo_mode is True and not demo_mode_enabled:
purge_active_rag_state(pipeline)
st.session_state["demo_mode_previous"] = demo_mode_enabled
add_system_log("Demo Mode disabled. Workspace state cleared.")
return
if demo_mode_enabled:
if not active_files:
purge_active_rag_state(pipeline)
st.session_state["auto_rebuild_error"] = (
f"No demo sources found in {DEMO_DATA_FOLDER.relative_to(PROJECT_ROOT)}."
)
st.session_state["demo_mode_previous"] = demo_mode_enabled
add_system_log("Demo Mode source file is missing.")
return
needs_rebuild = (
previous_demo_mode is not True
or source_signature_changed
or get_vector_count_safely(pipeline) == 0
)
if needs_rebuild:
st.session_state["chat_history"] = []
st.session_state["last_result"] = None
st.session_state["context_recommendation_preview"] = None
rebuild_knowledge_base(pipeline, files=active_files)
show_toast("Demo source prepared.", "✅")
st.session_state["demo_mode_previous"] = demo_mode_enabled
return
if not active_files:
if (
previous_demo_mode is None
or get_vector_count_safely(pipeline) > 0
or st.session_state.get("chat_history")
or st.session_state.get("recommended_questions")
):
purge_active_rag_state(pipeline)
add_system_log("Empty source mode initialized.")
st.session_state["demo_mode_previous"] = demo_mode_enabled
return
if source_signature_changed:
st.session_state["active_source_signature"] = active_signature
vector_count = get_vector_count_safely(pipeline)
if vector_count > 0:
st.session_state["knowledge_base_ready"] = True
refresh_recommended_questions_if_needed(
data_folder=pipeline.config.data_folder,
files=active_files,
)
else:
st.session_state["knowledge_base_ready"] = False
st.session_state["recommended_questions"] = []
st.session_state["demo_mode_previous"] = demo_mode_enabled
def build_debug_markdown(result: Dict[str, Any]) -> str:
"""
Build short debug information to show inside the chat when enabled.
"""
return f"""
---
### Debug Summary
- Provider: `{result.get("provider")}`
- Model: `{result.get("model")}`
- Embedding model: `{result.get("embedding_model")}`
- Prompt version: `{result.get("prompt_template_version")}`
- Retrieved chunks: `{result.get("top_k")}`
- Total time: `{result.get("total_elapsed_seconds")} seconds`
"""
def render_assistant_metadata(metadata: Dict[str, str]) -> None:
"""
Render assistant metadata using native Streamlit columns.
"""
metadata_items = [
("Model", metadata.get("model", "N/A")),
("Retrieved chunks", metadata.get("retrieved_chunks", "0")),
("Processing time", metadata.get("processing_time", "N/A")),
("Saved output", metadata.get("saved_output", "N/A")),
]
metadata_columns = st.columns([1.15, 1.0, 1.0, 1.7], gap="small")
for column, (label, value) in zip(metadata_columns, metadata_items):
with column:
st.caption(label)
st.markdown(f"`{value}`")
def render_retrieved_references(retrieved_chunks: List[Dict[str, Any]]) -> None:
"""
Render retrieved chunks inside a native expandable section.
"""
with st.expander("📌 Show retrieved references", expanded=False):
if not retrieved_chunks:
st.caption("No retrieved references available.")
return
for index, item in enumerate(retrieved_chunks):
source = item.get("source", "unknown")
rank = item.get("rank", "N/A")
chunk_index = item.get("chunk_index", "N/A")
distance = item.get("distance", 0.0)
text = str(item.get("text", "")).strip()
preview = text[:700]
if len(text) > 700:
preview = f"{preview}..."
st.markdown(f"**Source:** `{source}`")
st.caption(
f"Rank {rank} | Chunk {chunk_index} | Distance {float(distance):.4f}"
)
st.markdown(preview)
if index < len(retrieved_chunks) - 1:
st.divider()
# ============================================================
# STEP 14: SOURCES PANEL
# ============================================================
def display_sources_panel(pipeline: RAGPipeline) -> None:
"""
Left panel:
- Upload sources
- Rebuild source index
- View source files
- Delete source files
"""
config = pipeline.config
files = get_active_knowledge_base_files(config.data_folder)
with st.container(border=True, key="sources_panel"):
st.subheader("📚 Sources")
st.markdown(
"<div class='panel-caption'>Add files to your knowledge base. Answers are grounded in these sources.</div>",
unsafe_allow_html=True,
)
st.markdown(
f"""
<div class="sources-summary-row">
<div class="sources-summary-stat">
<span class="sources-summary-label">Files</span>
<span class="sources-summary-value">{len(files)}</span>
</div>
<div class="sources-summary-divider"></div>
<div class="sources-summary-stat">
<span class="sources-summary-label">Size</span>
<span class="sources-summary-value">
{get_total_file_size_kb(files):.1f}<span class="sources-summary-unit">KB</span>
</span>
</div>
</div>
""",
unsafe_allow_html=True,
)
st.markdown("<div class='sources-section-gap'></div>", unsafe_allow_html=True)
uploaded_files = st.file_uploader(
"Upload sources",
type=["txt", "md", "csv", "pdf"],
accept_multiple_files=True,
help="Supported files: TXT, Markdown, CSV, PDF",
)
upload_col, rebuild_col = st.columns(2)
with upload_col:
if st.button("Add sources", use_container_width=True):
if not uploaded_files:
st.warning("Upload at least one file.")
else:
try:
upload_folder = RAW_DATA_FOLDER
saved_paths = save_uploaded_files(
uploaded_files=uploaded_files,
data_folder=upload_folder,
)
register_custom_source_files(
saved_paths=saved_paths,
data_folder=upload_folder,
)
st.session_state["last_uploaded_files"] = [
str(path.relative_to(PROJECT_ROOT)) for path in saved_paths
]
rebuild_knowledge_base(pipeline)
if is_demo_mode_enabled():
show_toast(
(
f"Saved {len(saved_paths)} custom source(s). "
"Demo Mode keeps the demo source indexed."
),
"✅",
)
else:
show_toast(f"Added and indexed {len(saved_paths)} source(s).", "✅")
add_system_log(f"Added {len(saved_paths)} source file(s).")
st.rerun()
except Exception as error:
st.error("Upload failed.")
st.code(str(error))
with rebuild_col:
if st.button("Rebuild", use_container_width=True):
try:
rebuild_knowledge_base(pipeline)
show_toast("Sources rebuilt.", "✅")
st.rerun()
except Exception as error:
st.error("Rebuild failed.")
st.code(str(error))
st.markdown("<div class='sources-section-gap'></div>", unsafe_allow_html=True)
with st.container(height=320, key="sources_list_scroller"):
st.markdown(
f"""
<div class="sources-list-heading">
<div class="sources-list-title">Source library</div>
<div class="sources-list-count">{len(files)} total</div>
</div>
""",
unsafe_allow_html=True,
)
st.session_state["source_search_text"] = st.text_input(
"Search sources",
value=st.session_state.get("source_search_text", ""),
placeholder="Filter by file name...",
label_visibility="collapsed",
)
search_text = st.session_state.get("source_search_text", "").lower().strip()
if search_text:
files_to_show = [
file_path for file_path in files if search_text in file_path.name.lower()
]
else:
files_to_show = files
if not files_to_show:
if search_text:
st.info("No matching sources found.")
else:
for index, file_path in enumerate(files_to_show):
relative_path = file_path.relative_to(PROJECT_ROOT)
file_size_kb = file_path.stat().st_size / 1024
modified_date = time.strftime(
"%Y-%m-%d",
time.localtime(file_path.stat().st_mtime),
)
file_type = file_path.suffix.lower().replace(".", "") or "file"
chip_class = (
file_type if file_type in {"pdf", "csv", "md", "txt"} else "txt"
)
st.markdown(
f"""
<div class="source-card">
<div class="source-card-top">
<div class="source-card-main">
<div class="source-name">{file_path.name}</div>
<div class="source-path">{relative_path}</div>
</div>
<div class="source-meta-row">
<span class="source-chip source-chip-{chip_class}">{file_type.upper()}</span>
<span class="source-meta-separator">•</span>
<span>{file_size_kb:.1f} KB</span>
<span class="source-meta-separator">•</span>
<span>Updated {modified_date}</span>
</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
delete_clicked = False
if is_demo_mode_enabled():
st.caption("Built-in demo source")
else:
delete_clicked = st.button(
"Delete source",
key=f"delete_source_{index}_{file_path.name}",
)
if delete_clicked:
try:
delete_knowledge_base_file(file_path, config.data_folder)
unregister_custom_source_file(file_path)
rebuild_knowledge_base(pipeline)
show_toast(f"Deleted {file_path.name}.", "🗑️")
add_system_log(f"Deleted source: {file_path.name}")
st.rerun()
except Exception as error:
st.error("Delete failed.")
st.code(str(error))
if st.session_state.get("last_uploaded_files"):
with st.expander("Last uploaded", expanded=False):
for uploaded_path in st.session_state["last_uploaded_files"]:
st.write(f"✅ `{uploaded_path}`")
# ============================================================
# STEP 15: CHAT PANEL
# ============================================================
def display_chat_history() -> None:
"""
Display chat messages using native Streamlit chat components.
"""
for message in st.session_state.get("chat_history", []):
role = message.get("role", "assistant")
with st.chat_message(role):
if role == "user":
st.markdown(message.get("content", ""))
continue
if message.get("message_type") == "assistant_result":
st.markdown(message.get("answer", ""))
with st.container():
render_assistant_metadata(message.get("metadata", {}))
if message.get("references_enabled", True):
render_retrieved_references(message.get("retrieved_chunks", []))
if message.get("debug_markdown"):
with st.expander("Debug summary", expanded=False):
st.markdown(message["debug_markdown"])
continue
st.markdown(message.get("content", ""))
def ask_question(pipeline: RAGPipeline, question: str) -> None:
"""
Ask a question through the RAG pipeline and save a structured
assistant response for native chat rendering.
"""
question = question.strip()
if not question:
return
active_files = get_active_knowledge_base_files(pipeline.config.data_folder)
vector_count = get_vector_count_safely(pipeline) if active_files else 0
if vector_count == 0:
st.session_state["chat_history"].append(
{
"role": "assistant",
"content": (
"The knowledge base is not ready yet. "
"Please add a source or rebuild the knowledge base first."
),
}
)
return
st.session_state["chat_history"].append(
{
"role": "user",
"content": question,
}
)
try:
with st.spinner("Searching sources and generating answer..."):
result = pipeline.ask(question, top_k=pipeline.config.top_k)
saved_path = pipeline.save_result(result)
except Exception as error:
st.session_state["chat_history"].append(
{
"role": "assistant",
"content": f"Something went wrong while generating the answer.\n\n`{error}`",
}
)
add_system_log(f"Answer failed: {question[:70]}")
return
saved_output_display = saved_path
try:
saved_output_display = str(Path(saved_path).resolve().relative_to(PROJECT_ROOT))
except Exception:
saved_output_display = str(Path(saved_path).name)
debug_markdown = ""
if st.session_state.get("show_debug_json", False):
debug_markdown = build_debug_markdown(result)
st.session_state["chat_history"].append(
{
"role": "assistant",
"message_type": "assistant_result",
"answer": result.get("answer", ""),
"metadata": {
"model": result.get("model", "N/A"),
"retrieved_chunks": str(len(result.get("retrieved_chunks", []))),
"processing_time": f"{result.get('total_elapsed_seconds', 'N/A')}s",
"saved_output": saved_output_display,
},
"retrieved_chunks": result.get("retrieved_chunks", []),
"references_enabled": bool(st.session_state.get("show_sources", True)),
"debug_markdown": debug_markdown,
}
)
st.session_state["last_result"] = result
add_system_log(f"Answered: {question[:70]}")
def display_suggested_questions(pipeline: RAGPipeline) -> None:
"""
Display dynamic question recommendations inside the chat window.
"""
active_files = get_active_knowledge_base_files(pipeline.config.data_folder)
if not active_files:
st.session_state["recommended_questions"] = []
return
refresh_recommended_questions_if_needed(
data_folder=pipeline.config.data_folder,
files=active_files,
)
questions = st.session_state.get("recommended_questions", [])
if not questions:
return
with st.container(height=210, key="suggested_questions_scroller"):
st.markdown(
"""
<div class="suggested-prompts-heading">Suggested questions</div>
<div class="suggested-prompts-caption">
Start with one of these prompts or scroll for more.
</div>
""",
unsafe_allow_html=True,
)
cols = st.columns(2, gap="small")
for index, question in enumerate(questions[:10]):
with cols[index % 2]:
if st.button(
question,
key=f"suggested_question_{index}",
use_container_width=True,
):
ask_question(pipeline, question)
st.rerun()
def display_question_input_form(vector_count: int) -> Optional[str]:
"""
Display the anchored chat input at the base of the workspace.
"""
return st.chat_input(
"Ask anything from your uploaded sources...",
disabled=vector_count == 0,
key="workspace_chat_input",
)
def display_demo_mode_awareness_note() -> None:
"""
Explain how the current source mode controls document routing.
"""
if is_demo_mode_enabled():
title = "Demo Mode is on"
message = (
"The conversation is grounded exclusively in files from "
"data/sample. Uploaded raw sources are saved separately and are "
"not indexed until Demo Mode is turned off."
)
else:
title = "Demo Mode is off"
message = (
"Sample content has been removed from the active workspace. "
"The app now targets data/raw; upload custom files and rebuild "
"to populate the source library, suggested prompts, and chat index."
)
st.markdown(
f"""
<div class="demo-awareness-note">
<strong>{title}</strong>
<span>{message}</span>
</div>
""",
unsafe_allow_html=True,
)
def display_chat_panel(pipeline: RAGPipeline, vector_count: int) -> None:
"""
Central chat workspace with a bordered conversation area,
containerized history, and an input anchored at the base.
"""
active_files = get_active_knowledge_base_files(pipeline.config.data_folder)
with st.container(border=True, key="chat_workspace"):
st.subheader("💬 Conversation Workspace")
st.caption(
"The full conversation stays inside this workspace while the input remains anchored at the bottom for a modern chat flow."
)
display_demo_mode_awareness_note()
history_height = 430 if st.session_state.get("chat_history") else 340
with st.container(height=history_height, key="chat_history"):
if not st.session_state.get("chat_history"):
if active_files:
with st.container(key="chat_welcome_state"):
st.markdown("#### Start chatting with your documents")
st.caption(
"Ask a question below or start with one of the suggested prompts. The conversation history will stay contained in this bordered workspace."
)
if vector_count == 0:
st.info("Add or rebuild sources before asking questions.")
else:
display_suggested_questions(pipeline)
else:
display_chat_history()
with st.container(key="chat_input_shelf"):
prompt = display_question_input_form(vector_count)
if prompt and prompt.strip():
ask_question(pipeline, prompt)
st.rerun()
# ============================================================
# STEP 16: RIGHT GUIDE PANEL
# ============================================================
def display_runtime_settings(pipeline: RAGPipeline) -> None:
"""
Runtime RAG settings.
"""
config = pipeline.config
with st.expander("⚙️ Runtime settings", expanded=False):
config.top_k = st.slider(
"Top-K retrieved chunks",
min_value=1,
max_value=10,
value=int(config.top_k),
step=1,
)
config.cloud_temperature = st.slider(
"LLM temperature",
min_value=0.0,
max_value=1.0,
value=float(config.cloud_temperature),
step=0.05,
)
config.cloud_max_completion_tokens = st.slider(
"Max answer tokens",
min_value=100,
max_value=2000,
value=int(config.cloud_max_completion_tokens),
step=100,
)
st.divider()
config.chunk_size = st.slider(
"Chunk size",
min_value=300,
max_value=2000,
value=int(config.chunk_size),
step=100,
help="Used during rebuild.",
)
config.chunk_overlap = st.slider(
"Chunk overlap",
min_value=0,
max_value=400,
value=int(config.chunk_overlap),
step=20,
help="Used during rebuild.",
)
st.session_state["show_sources"] = st.toggle(
"Attach references to answers",
value=bool(st.session_state.get("show_sources", True)),
help="References are hidden by default and visible only when clicked.",
)
st.session_state["show_debug_json"] = st.toggle(
"Show debug summary in chat",
value=bool(st.session_state.get("show_debug_json", False)),
)
def display_system_tools(pipeline: RAGPipeline) -> None:
"""
API test, rebuild summary, and logs.
"""
with st.expander("🛠 System tools", expanded=False):
api_col, clear_col = st.columns(2)
with api_col:
if st.button("Test API", use_container_width=True):
with st.spinner("Testing API..."):
try:
st.session_state["api_test_result"] = pipeline.llm_client.test_connection()
add_system_log("API test successful.")
show_toast("API connection works.", "✅")
except Exception as error:
st.session_state["api_test_result"] = str(error)
add_system_log("API test failed.")
show_toast("API connection failed.", "⚠️")
with clear_col:
if st.button("Clear chat", use_container_width=True):
st.session_state["chat_history"] = []
st.session_state["last_result"] = None
add_system_log("Chat cleared.")
st.rerun()
if st.session_state.get("api_test_result"):
st.caption("API result")
st.code(str(st.session_state["api_test_result"])[:600])
if st.session_state.get("last_rebuild_result"):
st.caption("Last rebuild")
st.json(st.session_state["last_rebuild_result"])
st.caption("System logs")
if st.session_state.get("system_log"):
for log_item in st.session_state["system_log"]:
st.write(log_item)
else:
st.write("No logs yet.")
def display_future_recommendation_panel(pipeline: RAGPipeline) -> None:
"""
Placeholder for future context-aware recommendations.
"""
st.markdown(
"""
<div class="future-box">
<strong>Future recommendation feature</strong><br>
Later, this area can sample random chunks from uploaded sources and ask the LLM
to generate context-based suggested questions.
</div>
""",
unsafe_allow_html=True,
)
if st.button("Preview random source context", use_container_width=True):
st.session_state["context_recommendation_preview"] = (
create_context_recommendation_preview(pipeline.config.data_folder)
)
add_system_log("Random context preview generated.")
if st.session_state.get("context_recommendation_preview"):
st.info(st.session_state["context_recommendation_preview"])
def display_guide_panel(pipeline: RAGPipeline, vector_count: int) -> None:
"""
Right panel:
- Notebook guide
- Settings
- System tools
- Future recommendation placeholder
"""
config = pipeline.config
files = get_active_knowledge_base_files(config.data_folder)
with st.container(border=True, key="guide_panel"):
st.subheader("🧭 Settings")
st.markdown(
"<div class='panel-caption'>Manage runtime settings and review system state. Answer references are shown inside the chat.</div>",
unsafe_allow_html=True,
)
guide_col1, guide_col2 = st.columns(2)
with guide_col1:
st.metric("Sources", len(files))
with guide_col2:
st.metric("Chunks", vector_count)
st.info(f"Model: {config.cloud_chat_model}")
st.caption(f"Embedding: {config.embedding_model_name}")
with st.container(height=400, key="guide_tools_scroller"):
display_runtime_settings(pipeline)
display_system_tools(pipeline)
# ============================================================
# STEP 17: TOP HEADER
# ============================================================
def display_top_header(pipeline: RAGPipeline, vector_count: int) -> None:
"""
Display clean app header toolbar.
"""
demo_mode_enabled = is_demo_mode_enabled()
mode_text = "Sample" if demo_mode_enabled else "Raw"
status_text = "Sources ready" if vector_count > 0 else "Sources not indexed"
status_dot_class = "top-header-status-dot-ready" if vector_count > 0 else ""
with st.container(key="top_header"):
brand_col, metrics_col, toggle_col = st.columns([2.3, 1.7, 1.0], gap="small")
with brand_col:
st.markdown(
"""
<div class="top-header-brand">
<div class="top-header-mark">K</div>
<div class="top-header-copy">
<h1 class="top-header-title">KnowFlow AI</h1>
<span class="top-header-inline-divider">|</span>
<p class="top-header-tagline">Ask your documents. Trace every answer to its source.</p>
</div>
</div>
""",
unsafe_allow_html=True,
)
with metrics_col:
st.markdown(
f"""
<div class="top-header-metrics">
<div class="top-header-metric">
<span class="top-header-metric-label">Source</span>
<span class="top-header-metric-value">{mode_text}</span>
</div>
<div class="top-header-metric">
<span class="top-header-metric-label">Chunks</span>
<span class="top-header-metric-value">{vector_count}</span>
</div>
<div class="top-header-metric">
<span class="top-header-status-dot {status_dot_class}"></span>
<span class="top-header-metric-label">Status</span>
<span class="top-header-metric-value">{status_text}</span>
</div>
</div>
""",
unsafe_allow_html=True,
)
with toggle_col:
with st.container(key="demo_mode_header_control"):
st.toggle(
"Demo Mode",
key="demo_mode",
help="Switch between the sample demo repository and the raw upload repository.",
)
# ============================================================
# STEP 18: MAIN APP
# ============================================================
def main() -> None:
"""
Main Streamlit app.
"""
initialize_session_state()
pipeline = load_pipeline_safely()
if pipeline is None:
return
apply_data_folder_routing(pipeline)
config = pipeline.config
synchronize_demo_mode_state(pipeline)
active_files = get_active_knowledge_base_files(config.data_folder)
vector_count = get_vector_count_safely(pipeline) if active_files else 0
display_top_header(pipeline, vector_count)
with st.container(key="app_shell"):
sources_col, chat_col, guide_col = st.columns(
[0.9, 1.85, 1.0],
gap="large",
)
with sources_col:
display_sources_panel(pipeline)
with chat_col:
display_chat_panel(pipeline, vector_count)
with guide_col:
display_guide_panel(pipeline, vector_count)
# ============================================================
# STEP 19: ENTRY POINT
# ============================================================
if __name__ == "__main__":
main()