| |
|
|
| 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() |
|
|
|
|
| |
| 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, |
| ) |
| |
| |
| 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": [], |
| "_saved_starter_questions": [], |
| "pending_question": None, |
| "feedback_store": None, |
| } |
| 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 = [] |
| |
| 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 |
| st.session_state.clear_counter += 1 |
|
|
|
|
| def _rerun(): |
| if hasattr(st, "rerun"): |
| st.rerun() |
| elif hasattr(st, "experimental_rerun"): |
| st.experimental_rerun() |
|
|
|
|
| |
| 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 [] |
|
|
| |
| 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, |
| ) |
|
|
| |
| def _render_sidebar(): |
| with st.sidebar: |
| |
| 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) |
|
|
| |
| st.markdown(""" |
| <div class="dq-header"> |
| <div class="dq-logo">DQ</div> |
| <div class="dq-title">DocQuest</div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| st.markdown( |
| "<div class='sidebar-subtitle'>Personal Knowledge RAG</div>", |
| unsafe_allow_html=True |
| ) |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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() |
|
|
| |
| 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, |
| ) |
| |
| |
| 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: |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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" |
| |
| st.session_state.starter_questions = [] |
| st.session_state["_saved_starter_questions"] = [] |
|
|
| |
| if st.button("Clear all", type="secondary", use_container_width=True): |
| _clear_all() |
| _rerun() |
|
|
| |
| |
| |
| 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, |
| ) |
|
|
| |
| 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, |
| ) |
| |
| |
| def _render_steps(): |
| |
| 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 & embed</small></div> |
| <div class='step {chat_cls}'>3 Β· Chat<small>Ask questions</small></div> |
| </div>""", |
| unsafe_allow_html=True, |
| ) |
| |
| |
| 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, |
| ) |
| |
| |
| def _render_index_summary(): |
| if st.session_state.processing_status != "success": |
| return |
| |
| 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" |
|
|
| |
| 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, |
| ) |
|
|
| |
| def _render_source_card(source: dict): |
| pct = source["match_pct"] |
| chunk_raw = source.get("chunk_id", "β") |
| |
| 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, |
| ) |
|
|
| |
| |
| _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: |
| |
| |
| 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 |
|
|
| 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" |
|
|
| |
| 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 "" |
|
|
| |
| 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) |
|
|
| |
| steps = trace.steps if trace else [] |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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 |
|
|
| |
| |
| |
| pending = st.session_state.get("pending_question") |
| if pending: |
| st.session_state.pending_question = None |
| st.session_state.starter_questions = [] |
| |
| 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 |
| 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 |
|
|
| |
| if not st.session_state.chat_history and st.session_state.starter_questions: |
| questions_snapshot = list(st.session_state.starter_questions) |
|
|
| |
| def _html_escape(s): |
| return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) |
|
|
| cards_html = "" |
| for i, q in enumerate(questions_snapshot): |
| q_safe = _html_escape(q) |
| |
| 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, |
| ) |
|
|
| |
| |
| 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 |
|
|
| |
| |
| |
| 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, |
| ) |
| |
| |
| 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) |
|
|
| |
| user_question = st.chat_input("Ask anything about your documentsβ¦") |
| if not user_question: |
| return |
|
|
| |
| 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 |
| 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() |
|
|
| |
| 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, |
| ) |
|
|
|
|
| |
| _inject_styles() |
| _initialize_state() |
| _render_sidebar() |
| _render_demo_banner() |
| _render_steps() |
| _render_hero() |
| _render_index_summary() |
| _render_chat_interface() |
| _inject_border_fix() |
|
|