DocQuest / app.py
harao-ml's picture
Upload app.py
af4762e verified
Raw
History Blame Contribute Delete
83.2 kB
# app.py β€” DocQuest Β· Personal Knowledge RAG with Self-Healing
import re
from collections import Counter
import streamlit as st
import streamlit.components.v1 as components
from core.ingestion import DocumentLoader
from core.chunking import ChunkingPipeline
from core.embeddings import EmbeddingStore
from core.vector_store import VectorStoreManager
from core.generator import ChatGenerator
from config import (
check_ollama_server,
DEFAULT_MODEL,
AVAILABLE_MODELS,
EMBED_MODEL,
IS_HF_SPACES,
)
from core.self_healing import run_healing_pipeline, FeedbackStore, FeedbackStore
st.set_page_config(
page_title="DocQuest Β· Personal Knowledge RAG",
page_icon="πŸ“š",
layout="wide",
initial_sidebar_state="expanded",
)
check_ollama_server()
# ── Styles ──────────────────────────────────────────────────────────────────────
def _inject_styles():
st.markdown(
"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BASE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
* { font-family: 'Inter', system-ui, -apple-system, sans-serif !important; }
html, body, [class*='css'] { background: #0f0f11 !important; }
.stApp { background: #0f0f11 !important; }
.block-container { padding-top: 1.25rem; padding-bottom: 2rem; max-width: 1280px; }
h1, h2, h3, h4, h5, h6 { font-weight: 500; color: #e8e6f0; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SIDEBAR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
section[data-testid='stSidebar'] {
width: 250px !important;
min-width: 250px !important;
background: #131316 !important;
border-right: 0.5px solid rgba(255,255,255,0.07) !important;
}
section[data-testid='stSidebar'] > div { background: #131316 !important; }
.sidebar-subtitle { margin: 0 0 1rem; color: #6b6980; font-size: 0.82rem; }
.model-hint { font-size: 0.72rem; color: #4e4d60; margin: -6px 0 8px 2px; font-family: monospace; }
section[data-testid='stSidebar'] h4 {
font-size: 0.68rem !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #4e4d60 !important;
margin-bottom: 6px !important;
margin-top: 10px !important;
}
section[data-testid='stSidebar'] .stSelectbox > div > div {
background: rgba(127,119,221,0.08) !important;
border: 0.5px solid rgba(127,119,221,0.2) !important;
border-radius: 8px !important;
color: #afa9ec !important;
}
section[data-testid='stSidebar'] .stSelectbox svg { fill: #7F77DD !important; }
/* ── Sidebar: Process & Index (primary) β€” dark ── */
section[data-testid='stSidebar'] button[kind='primary'] {
background: #111118 !important;
border: 1px solid rgba(200,200,220,0.18) !important;
border-radius: 10px !important;
color: #fff !important;
font-size: 0.84rem !important;
font-weight: 700 !important;
padding: 10px 12px !important;
width: 100% !important;
box-shadow: none !important;
letter-spacing: 0.01em;
}
section[data-testid='stSidebar'] button[kind='primary']:hover {
background: #1e1e2e !important;
border-color: rgba(127,119,221,0.4) !important;
box-shadow: none !important;
}
section[data-testid='stSidebar'] button[kind='primary']:focus,
section[data-testid='stSidebar'] button[kind='primary']:active,
section[data-testid='stSidebar'] button[kind='primary']:focus-visible {
background: #1e1e2e !important;
box-shadow: none !important;
outline: none !important;
}
/* ── Sidebar: Clear all (secondary) ── */
section[data-testid='stSidebar'] button[kind='secondary'] {
background: transparent !important;
border: 0.5px solid rgba(255,255,255,0.1) !important;
border-radius: 10px !important;
margin-top: -10px !important;
color: #827fb5 !important;
font-size: 0.78rem !important;
box-shadow: none !important;
}
section[data-testid='stSidebar'] button[kind='secondary']:hover {
background: rgba(255,255,255,0.04) !important;
border-color: rgba(255,255,255,0.15) !important;
color: #827fb5 !important;
box-shadow: none !important;
}
section[data-testid='stSidebar'] button[kind='secondary']:focus,
section[data-testid='stSidebar'] button[kind='secondary']:active,
section[data-testid='stSidebar'] button[kind='secondary']:focus-visible {
box-shadow: none !important;
outline: none !important;
}
/* ── File uploader ── */
[data-testid="stFileUploader"] {
background: transparent !important;
border: 1.5px dashed rgba(127,119,221,0.35) !important;
border-radius: 10px !important;
padding: 8px !important;
}
[data-testid="stFileUploader"] > div { background: transparent !important; }
[data-testid="stFileUploaderDropzone"] small { display: none !important; }
/* Hide native "Drag and drop" text line */
[data-testid="stFileUploaderDropzone"] > div > div { color: #afa9ec !important; font-size: 0.8rem !important; }
[data-testid="stFileUploaderDropzone"] button,
[data-testid="stFileUploader"] button {
background: transparent !important;
color: #afa9ec !important;
border: 0.5px solid rgba(127,119,221,0.3) !important;
border-radius: 6px !important;
box-shadow: none !important;
display: none !important; /* hide native button to use own HTML label */
}
/* ── File cards ── */
.file-card {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
background: rgba(255,255,255,0.04);
border: 0.5px solid rgba(255,255,255,0.07);
border-radius: 7px; margin-bottom: 5px;
font-size: 0.8rem; color: #c4c2d8;
}
.file-card-name { font-weight: 500; color: #c4c2d8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 110px; }
.file-card-meta { color: #4e4d60; font-size: 0.7rem; margin-left: auto; white-space: nowrap; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUGGESTED QUESTION CARDS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
/* Hide the native hidden trigger buttons β€” they live in a stHorizontalBlock
that immediately follows the .sq-outer markdown div.
We target them by the sq_ key pattern Streamlit applies to the p-tag inside. */
.sq-outer ~ [data-testid="stHorizontalBlock"] {
position: absolute !important;
opacity: 0 !important;
pointer-events: none !important;
height: 0 !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DEMO BANNER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.demo-banner {
background: #0f0f13;
border-bottom: 0.5px solid rgba(255,255,255,0.07);
padding: 9px 18px;
font-size: 0.82rem;
color: #6b6980;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 10px;
}
.demo-dot {
width: 8px;
height: 8px;
min-width: 8px;
background: #7F77DD;
border-radius: 50%;
flex-shrink: 0;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEPS BAR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.steps-bar {
display: flex;
margin: 0 0 1.25rem;
border-bottom: 0.5px solid rgba(255,255,255,0.07);
background: #0f0f13;
}
.step {
flex: 1;
padding: 11px 16px;
font-size: 0.82rem;
font-weight: 400;
text-align: left;
background: transparent;
color: #4e4d60;
border-right: 0.5px solid rgba(255,255,255,0.07);
cursor: default;
}
.step:last-child { border-right: none; }
.step small { display: block; font-weight: 400; font-size: 0.69rem; margin-top: 2px; opacity: 0.8; }
.step.done { color: #1D9E75; }
.step.done small { color: #1D9E75; opacity: 0.7; }
.step.active { color: #e8e6f0; font-weight: 500; }
.step.active small { color: #6b6980; opacity: 1; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HERO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.hero-header { text-align: center; padding: 1.25rem 1rem 0; }
.hero-header h2 { color: #afa9ec; font-size: clamp(1.6rem,3.5vw,2.2rem); margin-bottom: 0.6rem; letter-spacing: -0.03em; line-height: 1.15; }
.hero-header p { color: #6b6980; font-size: 0.92rem; max-width: 560px; margin: 0 auto 1.25rem; line-height: 1.65; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap: 0.75rem; margin-bottom: 1.25rem; }
.feature-card { padding: 1rem 1.25rem; background: #131316; border: 0.5px solid rgba(255,255,255,0.07); border-radius: 12px; }
.feature-card h4 { margin: 0.5rem 0 0.3rem; color: #c4c2d8; font-size: 0.88rem; font-weight: 500; }
.feature-card p { color: #4e4d60; font-size: 0.8rem; line-height: 1.55; margin: 0; }
.feature-icon { font-size: 1.1rem; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TOPBAR / INDEX SUMMARY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.topbar {
display: flex; align-items: center; gap: 8px;
padding: 0 0 10px;
border-bottom: 0.5px solid rgba(255,255,255,0.06);
margin-bottom: 16px;
}
.crumb { font-size: 0.75rem; color: #4e4d60; }
.crumb-sep { color: #2e2d3a; font-size: 0.75rem; }
.crumb-active { color: #c4c2d8; font-weight: 500; }
.indexed-badge {
margin-left: auto;
background: rgba(29,158,117,0.1);
border: 0.5px solid rgba(29,158,117,0.2);
border-radius: 5px; padding: 3px 9px;
font-size: 0.7rem; color: #1D9E75; font-weight: 500;
}
.index-summary {
display: flex; align-items: center; gap: 12px;
background: #131316; border: 0.5px solid rgba(255,255,255,0.07);
border-radius: 10px; padding: 12px 16px; margin-bottom: 1rem;
}
.index-summary .dq-badge {
width: 36px; height: 36px; background: #7F77DD; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 500; font-size: 0.82rem; flex-shrink: 0;
}
.index-summary .meta { flex: 1; min-width: 0; }
.index-summary .meta h4 { margin: 0 0 2px; font-size: 0.88rem; color: #c4c2d8; font-weight: 500; }
.index-summary .meta p { margin: 0; font-size: 0.75rem; color: #4e4d60; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAT MESSAGES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.stChatMessage {
background: #131316 !important;
border: 0.5px solid rgba(255,255,255,0.07) !important;
border-radius: 10px !important;
}
.stChatMessage p, .stChatMessage li {
color: #dddbe8 !important;
font-size: 0.88rem !important;
line-height: 1.65 !important;
}
[data-testid='stChatMessageContent'] { color: #dddbe8 !important; }
.main-chat-heading { font-size: 0.95rem; font-weight: 500; color: #c4c2d8; margin: 0 0 1rem; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ANSWER CARD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.answer-card {
background: rgba(127,119,221,0.06);
border: 0.5px solid rgba(127,119,221,0.18);
border-radius: 10px; padding: 12px 16px; margin-bottom: 8px;
}
.answer-src-count { color: #4e4d60; font-size: 0.72rem; margin-left: 4px; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BADGES (faithfulness + filtered)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.badge-row { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
.trust-badge {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(29,158,117,0.1);
border: 0.5px solid rgba(29,158,117,0.25);
border-radius: 5px; padding: 3px 9px;
}
.filtered-badge {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(127,119,221,0.08);
border: 0.5px solid rgba(127,119,221,0.2);
border-radius: 5px; padding: 3px 9px;
}
.badge-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.badge-dot-green { background: #1D9E75; }
.badge-dot-purple { background: #7F77DD; }
.trust-badge-text { font-size: 0.72rem; color: #1D9E75; font-weight: 500; }
.filtered-badge-text { font-size: 0.72rem; color: #afa9ec; font-weight: 500; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CONFIDENCE BAR (Cap. 5)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.conf-wrap {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px;
}
.conf-label { font-size: 0.68rem; color: #4e4d60; white-space: nowrap; }
.conf-bar-bg {
flex: 1; height: 3px;
background: rgba(255,255,255,0.06);
border-radius: 2px; overflow: hidden;
}
.conf-bar-fill { height: 100%; border-radius: 2px; }
.conf-bar-high { background: #1D9E75; }
.conf-bar-medium { background: #BA7517; }
.conf-bar-low { background: #A32D2D; }
.conf-pct {
font-size: 0.68rem; font-weight: 500; white-space: nowrap;
}
.conf-pct-high { color: #1D9E75; }
.conf-pct-medium { color: #BA7517; }
.conf-pct-low { color: #A32D2D; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
THUMBS FEEDBACK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
/* The horizontal block wrapping the thumbs row must be invisible */
.thumbs-row-block [data-testid="stHorizontalBlock"],
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"]) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 4px 0 2px !important;
gap: 4px !important;
align-items: center !important;
min-height: 0 !important;
}
/* All columns in the thumbs row */
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"])
[data-testid="stColumn"] {
background: transparent !important;
padding: 0 !important;
min-width: 0 !important;
}
/* The actual thumb buttons */
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"])
button[data-testid="baseButton-secondary"] {
background: transparent !important;
border: 0.5px solid rgba(255,255,255,0.1) !important;
border-radius: 5px !important;
padding: 0 6px !important;
font-size: 13px !important;
height: 26px !important;
min-height: 0 !important;
line-height: 1 !important;
box-shadow: none !important;
width: auto !important;
min-width: 0 !important;
color: #6b6980 !important;
}
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"])
button[data-testid="baseButton-secondary"]::before,
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"])
button[data-testid="baseButton-secondary"]::after {
display: none !important;
content: none !important;
}
[data-testid="stHorizontalBlock"]:has(button[data-testid="baseButton-secondary"])
button[data-testid="baseButton-secondary"]:hover {
background: rgba(255,255,255,0.06) !important;
border-color: rgba(255,255,255,0.22) !important;
box-shadow: none !important;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SELF-HEALING PANEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.self-heal-wrap { margin-bottom: 12px; }
.self-heal-box {
background: #0f0f11;
border: 0.5px solid rgba(255,255,255,0.07);
border-radius: 8px; overflow: hidden;
}
.self-heal-header-left { display: flex; align-items: center; gap: 7px; }
.self-heal-icon {
width: 16px; height: 16px;
background: rgba(127,119,221,0.1);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.self-heal-icon-inner { width: 7px; height: 7px; border: 1.5px solid #7F77DD; border-radius: 1px; }
.self-heal-title { font-size: 0.72rem; font-weight: 500; color: #6b6980; }
.sh-chev { font-size: 0.65rem; color: #4e4d60; transition: transform 0.2s ease; display: inline-block; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SOURCES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.sources-label {
font-size: 0.68rem; font-weight: 500; text-transform: uppercase;
letter-spacing: 0.07em; color: #4e4d60; margin: 10px 0 7px;
display: flex; align-items: center; gap: 8px;
}
.sources-label::after { content: ''; flex: 1; height: 0.5px; background: rgba(255,255,255,0.06); }
.src-card {
background: #131316;
border: 0.5px solid rgba(255,255,255,0.07);
border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
display: flex; gap: 12px; align-items: flex-start;
transition: border-color 0.15s;
}
.src-card:hover { border-color: rgba(127,119,221,0.35); }
.src-body { flex: 1; min-width: 0; }
.src-header { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; flex-wrap: wrap; }
.src-file { font-size: 0.78rem; font-weight: 600; color: #c4c2d8; }
.src-chunk { font-size: 0.64rem; color: #3a3950; background: rgba(255,255,255,0.03); border-radius: 3px; padding: 1px 5px; letter-spacing: 0.01em; }
.src-excerpt { font-size: 0.75rem; color: #6b6980; line-height: 1.5; margin: 0; }
.src-score-col { text-align: right; flex-shrink: 0; min-width: 44px; }
.src-pct { font-size: 0.82rem; font-weight: 500; color: #7F77DD; }
.src-bar-bg { height: 3px; background: rgba(255,255,255,0.06); border-radius: 2px; margin-top: 4px; width: 36px; overflow: hidden; }
.src-bar-fill { height: 100%; background: #7F77DD; border-radius: 2px; }
.src-match-label { font-size: 0.64rem; color: #4e4d60; margin-top: 2px; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NOT-FOUND STATE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.not-found-block {
background: rgba(186,117,23,0.07);
border: 0.5px solid rgba(186,117,23,0.2);
border-radius: 10px; padding: 11px 14px;
display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px;
}
.nf-text { font-size: 0.85rem; color: #BA7517; font-weight: 500; }
.nf-sub { font-size: 0.75rem; color: #5f5030; margin-top: 3px; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAT INPUT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
[data-testid='stChatInput'] button {
background: #23233a !important;
color: #fff !important;
border-radius: 10px !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
border: none !important;
box-shadow: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: auto 4px auto 0 !important;
transition: background 0.15s !important;
}
[data-testid='stChatInput'] button:hover {
background: #32324e !important;
color: #fff !important;
box-shadow: none !important;
outline: none !important;
}
[data-testid='stChatInput'] button:focus {
box-shadow: none !important;
outline: none !important;
}
[data-testid='stChatInput'] button svg {
stroke: #ffffff !important;
fill: none !important;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
KILL STREAMLIT EXPANDER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
details[data-testid='stExpander'] { background: transparent !important; border: none !important; box-shadow: none !important; }
details[data-testid='stExpander'] summary { display: none !important; }
details[data-testid='stExpander'] > div { padding: 0 !important; }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCROLLBAR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
@media (max-width: 960px) {
.hero-header h2 { font-size: 1.5rem; }
.feature-grid { grid-template-columns: 1fr 1fr; }
.steps-bar { flex-wrap: wrap; }
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PROGRESS BAR β€” purple
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
[data-testid="stProgressBar"],
[data-testid="stProgressBar"] > div,
[data-testid="stProgressBar"] > div > div,
div[role="progressbar"],
div[role="progressbar"] > div,
div[role="progressbar"] > div > div,
.stProgress > div > div > div > div {
background: #7F77DD !important;
background-color: #7F77DD !important;
}
[data-testid="stProgressBar"] {
background: rgba(127,119,221,0.15) !important;
background-color: rgba(127,119,221,0.15) !important;
border-radius: 4px !important;
}
</style>
""",
unsafe_allow_html=True,
)
# ── Session state ────────────────────────────────────────────────────────────────
def _initialize_state():
defaults = {
"documents": [],
"vector_store": None,
"chat_history": [],
"processing_status": None,
"processing_message": None,
"processing_message_type": None,
"selected_model": DEFAULT_MODEL,
"selected_vector_db": "Chroma",
"uploaded_files": [],
"clear_counter": 0,
"starter_questions": [], # shown when chat is empty
"_saved_starter_questions": [], # mirror so New Chat can restore them
"pending_question": None, # set by chip click, consumed by input handler
"feedback_store": None, # FeedbackStore instance (Cap. 6)
}
for key, value in defaults.items():
if key not in st.session_state:
st.session_state[key] = value
def _clear_all():
"""Full reset β€” wipes documents, vector store, chat, and uploads."""
st.session_state.documents = []
st.session_state.vector_store = None
st.session_state.chat_history = []
st.session_state.processing_status = None
st.session_state.processing_message = None
st.session_state.processing_message_type = None
st.session_state.uploaded_files = []
# Wipe both the visible list and the saved mirror
st.session_state.starter_questions = []
st.session_state["_saved_starter_questions"] = []
st.session_state.pending_question = None
st.session_state.selected_model = DEFAULT_MODEL
st.session_state.selected_vector_db = "Chroma"
st.session_state.feedback_store = None # reset feedback on full clear
st.session_state.clear_counter += 1
def _rerun():
if hasattr(st, "rerun"):
st.rerun()
elif hasattr(st, "experimental_rerun"):
st.experimental_rerun()
# ── Helpers ──────────────────────────────────────────────────────────────────────
def _excerpt_text(text: str, max_chars: int = 260) -> str:
cleaned = re.sub(r"[#\*`\_\[\]()>~\-]+", "", text)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned[:max_chars].rstrip() + "…" if len(cleaned) > max_chars else cleaned
def _score_to_pct(score: float) -> int:
pct = round((1.0 - score / 2.0) * 100)
return max(0, min(100, pct))
def _get_model_display_name(model_id: str) -> str:
"""Return a short friendly name for a model ID."""
return AVAILABLE_MODELS.get(model_id, model_id) if isinstance(AVAILABLE_MODELS, dict) else model_id
def _generate_starter_questions(vector_store, generator) -> list[str]:
"""
Generate 3 questions grounded exclusively in the *just-indexed* content.
Pulls diverse chunks via multiple seed queries so questions reflect
the actual document, not any prior session.
"""
try:
seed_queries = [
"main topic subject overview",
"key details facts data",
"conclusions findings purpose",
]
seen_ids: set = set()
excerpts: list[str] = []
for seed in seed_queries:
results = vector_store.search(seed, k=2)
for doc, _ in results:
cid = doc.metadata.get("chunk_id") or doc.metadata.get("source") or id(doc)
if cid not in seen_ids:
seen_ids.add(cid)
cleaned = re.sub(r"\s+", " ", doc.page_content).strip()
excerpts.append(cleaned[:500])
if len(excerpts) >= 5:
break
if not excerpts:
return []
combined = "\n\n---\n\n".join(excerpts[:5])
system = (
"You generate short, specific questions that a user might ask about a document. "
"You must base your questions ONLY on the excerpts provided. "
"Do NOT ask generic questions about document systems, file formats, or software. "
"Every question must be answerable from the excerpt text."
)
user = (
f"Based ONLY on these excerpts from the document, generate exactly 3 short "
f"questions a user might ask about the content. "
f"Return only the 3 questions, one per line, no numbering, no bullet points.\n\n"
f"{combined}"
)
result = (
generator._call_hf(system=system, user=user)
if IS_HF_SPACES
else generator._call_ollama(system=system, user=user)
)
lines = [
q.strip().lstrip("β€’-123456789. ")
for q in result.strip().split("\n")
if q.strip()
]
return [q for q in lines if len(q) > 10][:3]
except Exception:
return []
# ── Document processing ──────────────────────────────────────────────────────────
def _load_documents(uploaded_files):
loader = DocumentLoader()
chunker = ChunkingPipeline()
all_chunks = []
progress = st.progress(0.0)
for idx, uploaded_file in enumerate(uploaded_files):
result = loader.load_file(uploaded_file)
chunks = chunker.split(result["text"], metadata=result["metadata"])
all_chunks.extend(chunks)
progress.progress((idx + 1) / len(uploaded_files))
return all_chunks
def _create_vector_store(documents, vector_db):
embedder = EmbeddingStore(model_name=EMBED_MODEL)
return VectorStoreManager.create_store(
vector_db,
documents=documents,
embedding_function=embedder.embeddings,
)
# ── Sidebar ──────────────────────────────────────────────────────────────────────
def _render_sidebar():
with st.sidebar:
# Global sidebar styling
st.markdown("""
<style>
/* Hide Streamlit's default sidebar header (the X row) */
section[data-testid="stSidebar"] > div:first-child > div:first-child {
display: none;
}
/* Reduce top padding after removing header */
section[data-testid="stSidebar"] .block-container {
padding-top: 0rem;
}
.dq-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0.75rem 0.85rem;
border-radius: 12px;
background: rgba(124, 58, 237, 0.12);
margin-bottom: 0.5rem;
flex-direction: row;
border: 1px solid rgba(124, 58, 237, 0.22);
}
.dq-logo {
width: 44px;
height: 44px;
min-width: 44px;
background: #7c3aed;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: 1rem;
letter-spacing: 0.02em;
box-shadow: 0 2px 12px rgba(124,58,237,0.45);
}
.dq-title {
display: inline-block;
font-size: 1.4rem;
font-weight: 800;
color: #ffffff;
white-space: nowrap;
letter-spacing: -0.02em;
}
.sidebar-subtitle {
font-size: 0.76rem;
font-weight: 400;
margin: 0 0 0.8rem 0.25rem;
color: #7b7a9a;
}
/* Tight spacing between elements */
section[data-testid="stSidebar"] div[data-testid="stVerticalBlock"] > div {
margin-bottom: 0.3rem;
}
section[data-testid="stSidebar"] h4 {
margin-top: 8px !important;
margin-bottom: 2px !important;
}
.file-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #1a1a20;
border: 0.5px solid #2e2e3a;
border-radius: 10px;
margin-bottom: 6px;
overflow: hidden;
}
.file-card-name {
font-weight: 500;
font-size: 0.82rem;
color: #c8c8d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.file-card-meta {
font-size: 0.72rem;
white-space: nowrap;
}
/* always hide native uploader file preview */
[data-testid="stFileUploaderFile"],
[data-testid="stFileUploaderFileData"],
[data-testid="stFileDeleteBtn"],
[data-testid="stFileUploaderFileName"] {
display: none !important;
}
</style>
""", unsafe_allow_html=True)
# Header
st.markdown("""
<div class="dq-header">
<div class="dq-logo">DQ</div>
<div class="dq-title">DocQuest</div>
</div>
""", unsafe_allow_html=True)
# Subtitle
st.markdown(
"<div class='sidebar-subtitle'>Personal Knowledge RAG</div>",
unsafe_allow_html=True
)
# ── Model selector with friendly names ────────────────────────────────
st.markdown("#### Language Model")
if isinstance(AVAILABLE_MODELS, dict):
friendly_names = list(AVAILABLE_MODELS.keys())
model_ids = list(AVAILABLE_MODELS.values())
if st.session_state.selected_model not in model_ids:
st.session_state.selected_model = model_ids[0]
current_idx = model_ids.index(st.session_state.selected_model)
chosen_name = st.selectbox(
"Language model", friendly_names, index=current_idx,
help="Model used for answer generation.",
label_visibility="collapsed",
)
st.session_state.selected_model = AVAILABLE_MODELS[chosen_name]
st.markdown(
f"<p class='model-hint'>{st.session_state.selected_model}</p>",
unsafe_allow_html=True,
)
else:
if st.session_state.selected_model not in AVAILABLE_MODELS:
st.session_state.selected_model = DEFAULT_MODEL
st.session_state.selected_model = st.selectbox(
"Language model", AVAILABLE_MODELS,
index=AVAILABLE_MODELS.index(st.session_state.selected_model),
help="Model used for answer generation.",
label_visibility="collapsed",
)
# ── Vector DB (hidden in HF Spaces) ──────────────────────────────────
if not IS_HF_SPACES:
st.markdown("#### Vector Database")
st.session_state.selected_vector_db = st.selectbox(
"Vector database", ["Chroma", "Pinecone"],
index=["Chroma", "Pinecone"].index(st.session_state.selected_vector_db),
help="Where embeddings are stored.",
label_visibility="collapsed",
)
# ── File uploader ─────────────────────────────────────────────────────
st.markdown("#### Upload Documents")
if st.session_state.processing_status != "success":
if not st.session_state.uploaded_files:
st.markdown(
"""
<style>
/* Single dotted-border upload box */
[data-testid="stFileUploader"] {
background: transparent !important;
border: none !important;
padding: 0 !important;
}
[data-testid="stFileUploaderDropzone"] {
background: transparent !important;
border: 1.5px dashed rgba(127,119,221,0.35) !important;
border-radius: 10px !important;
padding: 16px 12px 14px !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
min-height: 90px !important;
}
[data-testid="stFileUploaderDropzone"]:hover {
border-color: #7F77DD !important;
background: rgba(127,119,221,0.04) !important;
}
/* Hide ALL native dropzone text/instructions/button */
[data-testid="stFileUploaderDropzoneInstructions"],
[data-testid="stFileUploaderDropzone"] > div > small,
[data-testid="stFileUploaderDropzone"] > div > span,
[data-testid="stFileUploaderDropzone"] button {
display: none !important;
}
/* Inject arrow + label via ::before and ::after on the dropzone */
[data-testid="stFileUploaderDropzone"]::before {
content: '↑';
display: block;
font-size: 1.9rem;
color: #7F77DD;
line-height: 1;
margin-bottom: 8px;
text-align: center;
}
[data-testid="stFileUploaderDropzone"]::after {
content: 'Click to upload';
white-space: pre;
display: block;
text-align: center;
font-size: 0.82rem;
color: #afa9ec;
font-weight: 500;
line-height: 1.7;
}
</style>
""",
unsafe_allow_html=True,
)
uploaded = st.file_uploader(
"Upload documents",
type=["pdf", "docx", "pptx", "txt", "md"],
accept_multiple_files=True,
help="PDF, DOCX, PPTX, TXT, MD β€” max 25 MB each",
label_visibility="collapsed",
key=f"file_uploader_{st.session_state.clear_counter}",
)
if uploaded:
st.session_state.uploaded_files = uploaded
_rerun()
# ── Show selected-file pills when files chosen but not yet processed ──
if st.session_state.uploaded_files and st.session_state.processing_status != "success":
for f in st.session_state.uploaded_files:
ext = f.name.rsplit(".", 1)[-1].upper() if "." in f.name else "FILE"
size_kb = round(f.size / 1024, 1)
st.markdown(
f"<div style='display:flex;align-items:center;gap:9px;"
f"padding:8px 11px;margin-top:5px;"
f"background:rgba(127,119,221,0.07);"
f"border:0.5px solid rgba(127,119,221,0.25);"
f"border-radius:9px;'>"
f"<div style='width:30px;height:30px;min-width:30px;"
f"background:rgba(127,119,221,0.12);border:0.5px solid rgba(127,119,221,0.3);"
f"border-radius:6px;display:flex;align-items:center;"
f"justify-content:center;'>"
f"<div style='width:14px;height:17px;background:#7F77DD;border-radius:2px 5px 2px 2px;"
f"position:relative;opacity:0.7;'>"
f"<div style='position:absolute;top:0;right:0;width:5px;height:5px;"
f"background:#131316;border-radius:0 0 0 3px;'></div>"
f"</div></div>"
f"<div style='flex:1;min-width:0;'>"
f"<div style='font-size:0.79rem;font-weight:500;color:#c4c2d8;"
f"white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
f"max-width:148px;' title='{f.name}'>{f.name}</div>"
f"<div style='font-size:0.68rem;color:#6b6980;margin-top:1px;'>"
f"{ext} Β· {size_kb} KB</div>"
f"</div>"
f"<div style='font-size:0.68rem;color:#afa9ec;font-weight:500;"
f"background:rgba(127,119,221,0.12);border:0.5px solid rgba(127,119,221,0.25);"
f"border-radius:4px;padding:2px 7px;white-space:nowrap;'>Ready</div>"
f"</div>",
unsafe_allow_html=True,
)
# ── Process button ────────────────────────────────────────────────────
st.markdown("<div style='margin-top:6px;'></div>", unsafe_allow_html=True)
if st.button("β–Ά Process & Index", type="primary", use_container_width=True):
if not st.session_state.uploaded_files:
st.warning("Upload at least one document first.")
else:
# Wipe everything doc-specific BEFORE processing so stale
# questions never survive into the new session even briefly
st.session_state.starter_questions = []
st.session_state.chat_history = []
st.session_state.vector_store = None
st.session_state.documents = []
st.session_state.pending_question = None
with st.spinner("Chunking and embedding…"):
try:
chunks = _load_documents(st.session_state.uploaded_files)
store = _create_vector_store(
chunks, st.session_state.selected_vector_db
)
st.session_state.vector_store = store
st.session_state.documents = chunks
st.session_state.processing_status = "success"
st.session_state.processing_message = (
f"βœ… Indexed {len(chunks)} chunks from "
f"{len(st.session_state.uploaded_files)} file(s)."
)
st.session_state.processing_message_type = "success"
st.session_state.chat_history = []
st.session_state.pending_question = None
# Always wipe stale questions BEFORE generating
# new ones β€” if generation throws or returns [],
# the old doc's questions are already gone.
st.session_state.starter_questions = []
st.session_state["_saved_starter_questions"] = []
generator = ChatGenerator(model=st.session_state.selected_model)
questions = _generate_starter_questions(store, generator)
# Store in both the visible list and the saved mirror.
# The mirror lets _new_chat() restore chips without
# re-querying the LLM.
st.session_state.starter_questions = questions
st.session_state["_saved_starter_questions"] = list(questions)
except Exception as exc:
st.session_state.processing_status = "error"
st.session_state.processing_message = f"Processing failed: {exc}"
st.session_state.processing_message_type = "error"
# Ensure stale questions are never shown on error
st.session_state.starter_questions = []
st.session_state["_saved_starter_questions"] = []
# Clear all ──────────────────────────────────────────────
if st.button("Clear all", type="secondary", use_container_width=True):
_clear_all()
_rerun()
# ── Indexed file cards ────────────────────────────────────────────────
# Guard: skip during chip-click reruns to prevent double render.
# pending_question is set by chip click and consumed in _render_chat_interface.
if (
st.session_state.uploaded_files
and st.session_state.processing_status == "success"
and not st.session_state.get("pending_question")
):
st.markdown(
"<div style='font-size:0.68rem;font-weight:700;text-transform:uppercase;"
"letter-spacing:0.08em;color:#4e4d60;margin:14px 0 6px;'>"
"Indexed Files</div>",
unsafe_allow_html=True,
)
chunk_counts = Counter(
doc.metadata.get("filename", "unknown")
for doc in st.session_state.documents
)
seen: set = set()
for file in st.session_state.uploaded_files:
if file.name in seen:
continue
seen.add(file.name)
n_chunks = chunk_counts.get(file.name, "β€”")
size_kb = round(file.size / 1024, 1)
ext = file.name.rsplit(".", 1)[-1].upper() if "." in file.name else "FILE"
st.markdown(
f"<div style='display:flex;align-items:center;gap:10px;"
f"padding:9px 11px;background:#14141c;border:0.5px solid #252535;"
f"border-radius:10px;margin-bottom:6px;'>"
f"<div style='width:34px;height:34px;min-width:34px;"
f"background:#1c1c2c;border:0.5px solid #2e2e44;"
f"border-radius:7px;display:flex;align-items:center;"
f"justify-content:center;'>"
f"<div style='width:15px;height:18px;background:#7F77DD;border-radius:2px 5px 2px 2px;"
f"position:relative;opacity:0.65;'>"
f"<div style='position:absolute;top:0;right:0;width:5px;height:5px;"
f"background:#1c1c2c;border-radius:0 0 0 3px;'></div>"
f"</div></div>"
f"<div style='flex:1;min-width:0;'>"
f"<div style='font-size:0.8rem;font-weight:500;color:#c8c8d4;"
f"white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
f"max-width:128px;' title='{file.name}'>{file.name}</div>"
f"<div style='font-size:0.68rem;color:#4e4d60;margin-top:2px;'>"
f"{ext} Β· {n_chunks} chunks Β· {size_kb} KB</div>"
f"</div>"
f"<div style='width:22px;height:22px;min-width:22px;"
f"background:rgba(29,158,117,0.12);"
f"border:1.5px solid rgba(29,158,117,0.4);"
f"border-radius:50%;display:flex;align-items:center;"
f"justify-content:center;font-size:0.7rem;color:#1D9E75;"
f"font-weight:700;'>βœ“</div>"
f"</div>",
unsafe_allow_html=True,
)
# ── Demo banner ──────────────────────────────────────────────────────────────────
def _render_demo_banner():
if IS_HF_SPACES:
st.markdown(
"""<div class='demo-banner'>
<span class='demo-dot'></span>
<span><strong style='color:#c4c2d8;'>Demo mode</strong>
β€” documents held in memory for this session only.
First embedding may take ~30s.</span>
</div>""",
unsafe_allow_html=True,
)
# ── Step indicator ────────────────────────────────────────────────────────────────
def _render_steps():
# Suppress during chip-click intermediate rerun to avoid visible flash
if st.session_state.get("pending_question"):
return
has_files = bool(st.session_state.uploaded_files)
has_index = st.session_state.processing_status == "success"
has_chat = bool(st.session_state.chat_history)
upload_cls = "done" if has_files else ("active" if not has_files else "")
process_cls = "done" if has_index else ("active" if has_files else "")
chat_cls = "done" if has_chat else ("active" if has_index else "")
st.markdown(
f"""<div class='steps-bar'>
<div class='step {upload_cls}'>1 Β· Upload<small>Add your docs</small></div>
<div class='step {process_cls}'>2 Β· Process<small>Chunk &amp; embed</small></div>
<div class='step {chat_cls}'>3 Β· Chat<small>Ask questions</small></div>
</div>""",
unsafe_allow_html=True,
)
# ── Hero (shown only before indexing) ────────────────────────────────────────────
def _render_hero():
if st.session_state.processing_status == "success":
return
st.markdown(
"""<div class='hero-header'>
<div style='display:flex;justify-content:center;align-items:center;gap:0.6rem;margin-bottom:1rem;'>
<div style='display:inline-flex;width:40px;height:40px;background:#7F77DD;border-radius:10px;justify-content:center;align-items:center;color:white;font-size:1rem;font-weight:500;'>DQ</div>
<span style='font-size:1.4rem;font-weight:500;color:#e8e6f0;letter-spacing:-0.03em;'>DocQuest</span>
</div>
<h2>Transform documents into intelligent conversations</h2>
<p>Upload PDFs, DOCX, Markdown, and TXT files to create a searchable knowledge base β€” then chat with cited answers.</p>
</div>""",
unsafe_allow_html=True,
)
col1, col2, col3, col4 = st.columns(4)
cards = [
("πŸ“€", "Upload & index", "PDF, DOCX, MD, TXT β€” up to 25 MB each"),
("πŸ”", "Vector search", "Semantic embeddings for accurate retrieval"),
("πŸ’¬", "Chat with sources", "Answers with inline citations and excerpts"),
("πŸ”’", "Private by default", "Session-only β€” nothing stored permanently"),
]
for col, (icon, title, desc) in zip([col1, col2, col3, col4], cards):
with col:
st.markdown(
f"""<div class='feature-card'>
<div class='feature-icon'>{icon}</div>
<h4>{title}</h4>
<p>{desc}</p>
</div>""",
unsafe_allow_html=True,
)
# ── Indexed summary (shown instead of hero after indexing) ───────────────────────
def _render_index_summary():
if st.session_state.processing_status != "success":
return
# Suppress during chip-click intermediate rerun to avoid visible flash
if st.session_state.get("pending_question"):
return
n_docs = len(st.session_state.uploaded_files)
n_chunks = len(st.session_state.documents)
names = ", ".join(f.name for f in st.session_state.uploaded_files[:2])
if n_docs > 2:
names += f" +{n_docs - 2} more"
# Topbar breadcrumb row
st.markdown(
f"""<div class='topbar'>
<span class='crumb'>Knowledge base</span>
<span class='crumb-sep'>β€Ί</span>
<span class='crumb crumb-active'>{names}</span>
<div class='indexed-badge'>{n_docs} doc{'s' if n_docs > 1 else ''} indexed Β· {n_chunks} chunks</div>
</div>""",
unsafe_allow_html=True,
)
# ── Chat interface ────────────────────────────────────────────────────────────────
def _render_source_card(source: dict):
pct = source["match_pct"]
chunk_raw = source.get("chunk_id", "β€”")
# Chunk label is now secondary β€” rendered after filename in muted style
chunk_label = f"chunk {chunk_raw}" if chunk_raw not in ("β€”", "", None) else ""
st.markdown(
f"""<div class='src-card'>
<div class='src-body'>
<div class='src-header'>
<span class='src-file'>{source['filename']}</span>
{f"<span class='src-chunk'>{chunk_label}</span>" if chunk_label else ""}
</div>
<p class='src-excerpt'>{source['text']}</p>
</div>
<div class='src-score-col'>
<div class='src-pct'>{pct}%</div>
<div class='src-bar-bg'><div class='src-bar-fill' style='width:{pct}%;'></div></div>
<div class='src-match-label'>match</div>
</div>
</div>""",
unsafe_allow_html=True,
)
# Helper β€” detect queries where low retrieval scores are structurally expected
_SUMMARY_PATTERNS = re.compile(
r"^\s*(summarize|summarise|give me a summary|overview|what is this (doc|document|about)|"
r"tldr|tl;dr|brief me|describe this)",
re.IGNORECASE,
)
def _is_retrieval_limited_query(query: str, pct: int) -> bool:
"""True when low confidence is caused by retrieval structure, not a model failure."""
if pct <= 40:
# Anything at or below 40% is retrieval-limited by definition β€”
# the model found weak matches regardless of query type.
return True
if _SUMMARY_PATTERNS.match(query.strip()):
return True
return False
def _render_confidence(trace, query: str = ""):
"""Capability 5 β€” always render confidence bar below the answer card."""
if not trace:
return
pct = round(trace.confidence * 100)
tier = trace.confidence_label # 'high' | 'medium' | 'low'
if tier == "low" and _is_retrieval_limited_query(query, pct):
tier_text = "Retrieval limited"
bar_class = "conf-bar-medium"
pct_class = "conf-pct-medium"
else:
tier_text = {
"high": "High confidence",
"medium": "Medium confidence",
"low": "Low confidence",
}[tier]
bar_class = f"conf-bar-{tier}"
pct_class = f"conf-pct-{tier}"
st.markdown(
f"<div class='conf-wrap'>"
f"<span class='conf-label'>{tier_text}</span>"
f"<div class='conf-bar-bg'>"
f"<div class='conf-bar-fill {bar_class}' style='width:{pct}%;'></div>"
f"</div>"
f"<span class='conf-pct {pct_class}'>{pct}%</span>"
f"</div>",
unsafe_allow_html=True,
)
def _render_thumbs(turn_idx: int):
"""Capability 6 β€” thumbs feedback rendered as a clean HTML inline row."""
turn = st.session_state.chat_history[turn_idx]
existing = turn.get("feedback")
if existing:
icon = "πŸ‘" if existing == "up" else "πŸ‘Ž"
st.markdown(
f"<div style='display:flex;align-items:center;gap:5px;"
f"margin:4px 0 6px;background:transparent;'>"
f"<span style='font-size:0.65rem;color:#4e4d60;'>"
f"{icon} Feedback recorded</span></div>",
unsafe_allow_html=True,
)
return
col_up, col_dn, col_rest = st.columns([1, 1, 22])
with col_up:
if st.button("πŸ‘", key=f"tb_up_{turn_idx}", help="Helpful"):
_record_feedback(turn_idx, thumbs_up=True)
with col_dn:
if st.button("πŸ‘Ž", key=f"tb_dn_{turn_idx}", help="Not helpful"):
_record_feedback(turn_idx, thumbs_up=False)
def _record_feedback(turn_idx: int, thumbs_up: bool):
"""
Capability 6 β€” write feedback into session state FeedbackStore and
annotate the chat_history turn so buttons become static.
"""
turn = st.session_state.chat_history[turn_idx]
turn["feedback"] = "up" if thumbs_up else "down"
# Lazily initialise FeedbackStore
if st.session_state.feedback_store is None:
st.session_state.feedback_store = FeedbackStore()
trace = turn.get("healing_trace")
chunk_ids = trace.retrieved_chunk_ids if trace else []
st.session_state.feedback_store.record(
query=turn["user"],
chunk_ids=chunk_ids,
thumbs_up=thumbs_up,
)
_rerun()
def _render_self_healing(trace, always_show: bool = False):
"""
Render self-healing badges + collapsible audit panel.
When always_show=True, renders even if no healing actions fired
(shows a minimal 'Pipeline ran cleanly' state for UI consistency).
"""
if not trace:
return
any_fired = trace.any_healing_fired
badge_text = trace.summary_badge() if trace else ""
# ── Badge row ──────────────────────────────────────────────────────────
badges_html = (
"<div class='trust-badge'>"
"<div class='badge-dot badge-dot-green'></div>"
"<span class='trust-badge-text'>Faithfulness verified Β· no hallucination detected</span>"
"</div>"
)
if badge_text:
badges_html += (
f"<div class='filtered-badge'>"
f"<div class='badge-dot badge-dot-purple'></div>"
f"<span class='filtered-badge-text'>{badge_text}</span>"
f"</div>"
)
st.markdown(f"<div class='badge-row'>{badges_html}</div>", unsafe_allow_html=True)
# ── Collapsible audit panel ────────────────────────────────────────────
steps = trace.steps if trace else []
# If no healing fired and we're in always_show mode, synthesise a clean-run step
if not steps:
if not always_show:
return
steps = ["Pipeline ran cleanly Β· no interventions required"]
uid = abs(hash(str(steps)))
st.markdown(
f"""<style>
#sh-{uid}{{display:none}}
#sh-{uid}:checked~.shb-{uid}{{display:block!important}}
#sh-{uid}:checked~.shl-{uid} .shchev-{uid}{{transform:rotate(180deg)}}
.shl-{uid}{{display:flex;align-items:center;justify-content:space-between;
padding:8px 12px;cursor:pointer;user-select:none}}
.shl-{uid}:hover{{background:rgba(255,255,255,0.02)}}
.shchev-{uid}{{font-size:10px;color:#4e4d60;
transition:transform 0.2s ease;display:inline-block}}
</style>""",
unsafe_allow_html=True,
)
rows_html = ""
for step in steps:
s = step.lower()
if "pipeline ran cleanly" in s:
dot_color, text_color = "#1D9E75", "#1D9E75"
elif any(w in s for w in ("complete", "success", "passed", "indexed")):
dot_color, text_color = "#1D9E75", "#1D9E75"
elif any(w in s for w in ("failed", "error", "invalid")):
dot_color, text_color = "#A32D2D", "#A32D2D"
elif any(w in s for w in ("filtered", "low-relevance", "inferential",
"re-answer", "threshold", "fallback", "faithfulness check")):
dot_color, text_color = "#BA7517", "#BA7517"
elif "confidence:" in s:
dot_color, text_color = "#7F77DD", "#afa9ec"
elif any(w in s for w in ("feedback:", "rerank", "rewrite skipped", "threshold adjusted")):
dot_color, text_color = "#5b8dd9", "#8ab4f8"
else:
dot_color, text_color = "#4e4d60", "#6b6980"
rows_html += (
f"<div style='display:flex;align-items:flex-start;gap:8px;padding:5px 0;'>"
f"<span style='flex-shrink:0;margin-top:5px;width:5px;height:5px;"
f"border-radius:50%;background:{dot_color};display:inline-block'></span>"
f"<span style='font-size:11px;line-height:1.5;color:{text_color};"
f"font-family:Inter,sans-serif'>{step}</span>"
f"</div>"
)
st.markdown(
f"<div style='margin-bottom:12px'>"
f"<div style='background:#0f0f11;border:0.5px solid rgba(255,255,255,0.07);"
f"border-radius:8px;overflow:hidden'>"
f"<label class='shl-{uid}' for='sh-{uid}'>"
f"<div style='display:flex;align-items:center;gap:7px'>"
f"<div style='width:16px;height:16px;background:rgba(127,119,221,0.1);"
f"border-radius:4px;display:flex;align-items:center;justify-content:center'>"
f"<div style='width:7px;height:7px;border:1.5px solid #7F77DD;"
f"border-radius:1px'></div></div>"
f"<span style='font-size:11px;font-weight:500;color:#6b6980;"
f"font-family:Inter,sans-serif'>Pipeline details</span>"
f"</div>"
f"<span class='shchev-{uid}'>β–Ύ</span>"
f"</label>"
f"<input type='checkbox' id='sh-{uid}'>"
f"<div class='shb-{uid}' style='display:none;padding:6px 12px 8px;"
f"border-top:0.5px solid rgba(255,255,255,0.05)'>"
f"{rows_html}"
f"</div>"
f"</div></div>",
unsafe_allow_html=True,
)
# ── Factual query detector ────────────────────────────────────────────────────────
def _is_factual_query(query: str) -> bool:
factual_starters = ("who", "what", "when", "where", "which", "how many", "how much")
return len(query.split()) <= 8 and query.lower().startswith(factual_starters)
def _render_chat_interface():
if st.session_state.vector_store is None:
if st.session_state.processing_status != "success":
st.markdown(
"<p style='color:#4e4d60;font-size:0.88rem;text-align:center;padding:2rem 0;'>"
"Upload and process documents to start chatting.</p>",
unsafe_allow_html=True,
)
return
# ── Consume pending_question FIRST before any rendering ──────────────────
# This prevents the double-render: if a chip was clicked, we capture the
# question and clear state now, so the render below sees the final state.
pending = st.session_state.get("pending_question")
if pending:
st.session_state.pending_question = None
st.session_state.starter_questions = []
# Snapshot history BEFORE appending the new placeholder turn
history_for_context = list(st.session_state.chat_history)
st.session_state.chat_history.append({"user": pending, "assistant": "…"})
generator = ChatGenerator(model=st.session_state.selected_model)
generator._conversation_history = history_for_context # picked up by generate_answer
with st.spinner("Searching your documents…"):
try:
answer, relevant_docs, healing_trace = run_healing_pipeline(
query=pending,
vector_store=st.session_state.vector_store,
generator=generator,
k=6,
relevance_threshold=0.15 if _is_factual_query(pending) else 0.30,
feedback_store=st.session_state.feedback_store,
)
except RuntimeError as e:
err_msg = (
"⚠️ The demo's free API quota is temporarily exhausted. "
"Please try again later or run the app locally with Ollama."
if ("402" in str(e) or "Payment Required" in str(e) or "credits" in str(e).lower())
else f"⚠️ Something went wrong: {e}"
)
st.session_state.chat_history[-1] = {
"user": pending, "assistant": err_msg,
"sources": [], "healing_trace": None,
}
_rerun()
return
sources = []
for idx, (doc, score) in enumerate(relevant_docs, start=1):
meta = doc.metadata
sources.append({
"num": f"Source {idx}",
"filename": meta.get("filename") or meta.get("source") or f"Document {idx}",
"chunk_id": meta.get("chunk_id", "β€”"),
"match_pct": _score_to_pct(score),
"text": _excerpt_text(doc.page_content, max_chars=260),
"title": f"Source {idx}",
"match_label": f"{_score_to_pct(score)}% match",
})
st.session_state.chat_history[-1] = {
"user": pending, "assistant": answer,
"sources": sources, "healing_trace": healing_trace,
}
_rerun()
return
# ── Suggested question cards (shown when chat is empty) ───────────────────
if not st.session_state.chat_history and st.session_state.starter_questions:
questions_snapshot = list(st.session_state.starter_questions)
# Render the beautiful card UI via st.markdown (in parent frame, no iframe, no bg issues)
def _html_escape(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
cards_html = ""
for i, q in enumerate(questions_snapshot):
q_safe = _html_escape(q)
# data-idx used by the JS click handler below
cards_html += f"""
<div class="sq-card" data-idx="{i}">
<div class="sq-num">0{i+1}</div>
<div class="sq-text">{q_safe}</div>
<div class="sq-chip">Ask β†’</div>
</div>"""
st.markdown(
f"""
<style>
.sq-outer {{ margin: 0.75rem 0 0.25rem; }}
.sq-head {{
display: flex; align-items: center; gap: 7px; margin-bottom: 10px;
}}
.sq-head-icon {{ font-size: 0.6rem; color: rgba(127,119,221,0.6); }}
.sq-head-label {{
font-size: 0.65rem; font-weight: 700; color: #4e4d60;
letter-spacing: 0.1em; text-transform: uppercase;
}}
.sq-head-line {{ flex: 1; height: 0.5px; background: rgba(127,119,221,0.14); }}
.sq-wrap {{ display: flex; gap: 10px; width: 100%; }}
.sq-card {{
flex: 1; position: relative;
background: #131316;
border: 1px solid rgba(127,119,221,0.22);
border-radius: 12px;
padding: 14px 14px 42px 14px;
cursor: pointer; min-height: 105px; overflow: hidden;
transition: background 0.18s, border-color 0.18s, transform 0.14s, box-shadow 0.18s;
}}
.sq-card:hover {{
background: #1b1b24; border-color: rgba(127,119,221,0.42);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(127,119,221,0.1), 0 2px 6px rgba(0,0,0,0.4);
}}
.sq-card:active {{ transform: scale(0.98); box-shadow: none; }}
.sq-num {{
font-size: 0.58rem; font-weight: 700;
color: rgba(127,119,221,0.45); letter-spacing: 0.08em; margin-bottom: 8px;
}}
.sq-text {{
font-size: 0.84rem; font-weight: 400;
color: #c9c6d8; line-height: 1.55; letter-spacing: 0.005em;
}}
.sq-chip {{
position: absolute; bottom: 11px; left: 14px;
font-size: 0.6rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: rgba(127,119,221,0.55);
background: rgba(127,119,221,0.1); border: 1px solid rgba(127,119,221,0.18);
border-radius: 4px; padding: 2px 7px; pointer-events: none; line-height: 1.6;
transition: color 0.15s, background 0.15s;
}}
.sq-card:hover .sq-chip {{
color: rgba(127,119,221,0.9); background: rgba(127,119,221,0.16);
}}
</style>
<div class="sq-outer">
<div class="sq-head">
<span class="sq-head-icon">✦</span>
<span class="sq-head-label">Suggested questions</span>
<div class="sq-head-line"></div>
</div>
<div class="sq-wrap">{cards_html}</div>
</div>
""",
unsafe_allow_html=True,
)
# Hidden native st.buttons β€” visually suppressed via CSS but Streamlit-clickable.
# The components.html script below wires each .sq-card click to its matching button.
btn_cols = st.columns(len(questions_snapshot))
for i, (col, q) in enumerate(zip(btn_cols, questions_snapshot)):
with col:
if st.button(q, key=f"sq_{i}", use_container_width=True):
st.session_state.pending_question = q
st.session_state.starter_questions = []
_rerun()
return
# Wire card clicks β†’ hidden buttons via parent-frame JS.
# Buttons are found by matching their visible text to the question text.
# The same JS also hides the native buttons so only the styled cards show.
import json as _json
questions_json = _json.dumps(questions_snapshot)
components.html(
f"""
<script>
(function() {{
var questions = {questions_json};
function run() {{
var doc = window.parent.document;
// Hide all secondary buttons whose text matches a question
var allBtns = doc.querySelectorAll('button[kind="secondary"]');
allBtns.forEach(function(b) {{
var txt = b.textContent.trim();
if (questions.indexOf(txt) !== -1) {{
// Hide the button and its entire column wrapper
var col = b.closest('[data-testid="column"]');
if (col) col.style.cssText = 'position:absolute!important;opacity:0!important;pointer-events:none!important;height:0!important;overflow:hidden!important;margin:0!important;padding:0!important;';
else b.style.cssText = 'position:absolute!important;opacity:0!important;pointer-events:none!important;';
}}
}});
// Wire the styled cards to click the matching hidden button
var cards = doc.querySelectorAll('.sq-card[data-idx]');
cards.forEach(function(card) {{
if (card._wired) return;
card._wired = true;
var idx = parseInt(card.getAttribute('data-idx'));
var q = questions[idx];
card.addEventListener('click', function() {{
var btns = doc.querySelectorAll('button[kind="secondary"]');
for (var b of btns) {{
if (b.textContent.trim() === q) {{
b.click();
return;
}}
}}
}});
}});
}}
run();
var obs = new MutationObserver(run);
obs.observe(window.parent.document.body, {{childList:true, subtree:true}});
}})();
</script>
""",
height=0,
)
# ── Render chat history ───────────────────────────────────────────────────
for turn_idx, turn in enumerate(st.session_state.chat_history):
with st.chat_message("user"):
st.markdown(turn["user"])
with st.chat_message("assistant"):
n_src = len(turn.get("sources", []))
src_count_html = (
f"<span class='answer-src-count'>({n_src} source{'s' if n_src > 1 else ''})</span>"
if n_src else ""
)
st.markdown(
f"<div class='answer-card'>"
f"<span style='font-size:0.88rem;color:#dddbe8;line-height:1.65;'>"
f"{turn['assistant']}{src_count_html}</span>"
f"</div>",
unsafe_allow_html=True,
)
trace = turn.get("healing_trace")
_render_confidence(trace, query=turn.get("user", ""))
_render_thumbs(turn_idx)
_render_self_healing(trace, always_show=True)
if turn.get("sources"):
st.markdown("<div class='sources-label'>Sources</div>", unsafe_allow_html=True)
for src in turn["sources"]:
_render_source_card(src)
# ── Accept typed input ────────────────────────────────────────────────────
user_question = st.chat_input("Ask anything about your documents…")
if not user_question:
return
# Snapshot history BEFORE appending the new placeholder turn
history_for_context = list(st.session_state.chat_history)
st.session_state.chat_history.append({"user": user_question, "assistant": "…"})
generator = ChatGenerator(model=st.session_state.selected_model)
generator._conversation_history = history_for_context # picked up by generate_answer
with st.spinner("Searching your documents…"):
try:
answer, relevant_docs, healing_trace = run_healing_pipeline(
query=user_question,
vector_store=st.session_state.vector_store,
generator=generator,
k=6,
relevance_threshold=0.15 if _is_factual_query(user_question) else 0.30,
feedback_store=st.session_state.feedback_store,
)
except RuntimeError as e:
if "402" in str(e) or "Payment Required" in str(e) or "credits" in str(e).lower():
st.session_state.chat_history[-1] = {
"user": user_question,
"assistant": "⚠️ The demo's free API quota is temporarily exhausted. "
"Please try again later or run the app locally with Ollama.",
"sources": [],
"healing_trace": None,
}
else:
st.session_state.chat_history[-1] = {
"user": user_question,
"assistant": f"⚠️ Something went wrong: {e}",
"sources": [],
"healing_trace": None,
}
_rerun()
return
sources = []
for idx, (doc, score) in enumerate(relevant_docs, start=1):
meta = doc.metadata
sources.append({
"num": f"Source {idx}",
"filename": meta.get("filename") or meta.get("source") or f"Document {idx}",
"chunk_id": meta.get("chunk_id", "β€”"),
"match_pct": _score_to_pct(score),
"text": _excerpt_text(doc.page_content, max_chars=260),
"title": f"Source {idx}",
"match_label": f"{_score_to_pct(score)}% match",
})
st.session_state.chat_history[-1] = {
"user": user_question,
"assistant": answer,
"sources": sources,
"healing_trace": healing_trace,
}
_rerun()
# ── Border fix (components.html injection) ───────────────────────────────────────
def _inject_border_fix():
components.html(
"""
<style>
div[data-testid="stChatInput"] > div {
border: 0.5px solid rgba(255,255,255,0.1) !important;
border-radius: 14px !important;
background: rgba(255,255,255,0.04) !important;
box-shadow: none !important;
padding: 3px 4px !important;
}
div[data-testid="stChatInput"] > div:focus-within {
border-color: rgba(127,119,221,0.4) !important;
box-shadow: none !important;
}
div[data-testid="stChatInput"] textarea {
border: none !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
color: #c4c2d8 !important;
}
div[data-testid="stChatInput"] textarea::placeholder {
color: #4e4d60 !important;
}
div[data-testid="stChatInput"] textarea:focus {
border: none !important;
outline: none !important;
box-shadow: none !important;
}
div[data-testid="stChatInput"] button {
background: #23233a !important;
border-radius: 10px !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
border: none !important;
box-shadow: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: auto 4px !important;
transition: background 0.15s !important;
}
div[data-testid="stChatInput"] button:hover {
background: #32324e !important;
box-shadow: none !important;
}
div[data-testid="stChatInput"] button svg {
stroke: #ffffff !important;
fill: none !important;
}
*:focus-visible { outline: none !important; box-shadow: none !important; }
button:focus, button:active, button:focus-visible { outline: none !important; box-shadow: none !important; }
</style>
<script>
const style = document.querySelector('style');
if (style && window.parent && window.parent.document) {
const clone = style.cloneNode(true);
const existing = window.parent.document.getElementById('dq-chat-fix');
if (!existing) {
clone.id = 'dq-chat-fix';
window.parent.document.head.appendChild(clone);
}
}
// Override progress bar color
function fixProgressBar() {
const bars = window.parent.document.querySelectorAll(
'[data-testid="stProgressBar"] > div, div[role="progressbar"] > div'
);
bars.forEach(el => {
el.style.setProperty('background', '#7F77DD', 'important');
el.style.setProperty('background-color', '#7F77DD', 'important');
});
}
fixProgressBar();
const observer = new MutationObserver(fixProgressBar);
observer.observe(window.parent.document.body, { childList: true, subtree: true });
</script>
""",
height=0,
)
# ── Entry point ──────────────────────────────────────────────────────────────────
_inject_styles()
_initialize_state()
_render_sidebar()
_render_demo_banner()
_render_steps()
_render_hero()
_render_index_summary()
_render_chat_interface()
_inject_border_fix()