teachings-qa / app.py
ativilambit's picture
Upload app.py with huggingface_hub
13b075e verified
Raw
History Blame Contribute Delete
48.1 kB
"""
Streamlit web UI β€” Teachings Q&A
Two-column layout: chat left, sources right.
Saffron/espresso/cream palette.
Run:
venv/bin/streamlit run app.py
"""
import json
import os
import re
import sys
from pathlib import Path
sys.path.insert(0, ".")
import markdown as _md
import streamlit as st
import streamlit.components.v1 as _components
from answer import answer as get_answer
HISTORY_FILE = Path("data/chat_history.json")
def _password_gate() -> None:
"""Block access unless APP_PASSWORD env var is unset or correct password entered."""
required = os.environ.get("APP_PASSWORD", "").strip()
if not required:
return
if st.session_state.get("_auth"):
return
st.markdown("""
<style>
.auth-wrap { max-width:380px; margin:120px auto; text-align:center; }
.auth-wrap h2 { font-family:"Spectral",Georgia,serif; font-weight:400;
font-size:1.6em; color:#211F2A; margin-bottom:0.3em; }
.auth-wrap p { color:#6A6675; font-size:0.88em; margin-bottom:1.6em; }
</style>
<div class="auth-wrap">
<h2>✿ Teachings Q&amp;A</h2>
<p>Enter the password to continue.</p>
</div>
""", unsafe_allow_html=True)
pw = st.text_input("Password", type="password", label_visibility="collapsed",
placeholder="Password…")
if pw:
if pw == required:
st.session_state._auth = True
st.rerun()
else:
st.error("Incorrect password.")
st.stop()
def _load_history() -> list:
if HISTORY_FILE.exists():
try:
return json.loads(HISTORY_FILE.read_text())
except Exception:
return []
return []
def _save_history(messages: list) -> None:
HISTORY_FILE.write_text(json.dumps(messages, ensure_ascii=False, indent=2))
# ── page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Teachings Q&A",
page_icon="☸️",
layout="wide",
initial_sidebar_state="collapsed",
)
_password_gate()
# ── CSS ───────────────────────────────────────────────────────────────────────
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,600;1,400&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
/* ── Β§1 token system ── */
:root {
/* Neutrals */
--paper: #F4F3EF;
--surface: #FCFBF9;
--ink: #211F2A;
--ink-muted: #6A6675;
--hairline: #E6E3DC;
--hairline-strong: #D8D4CB;
/* Chrome accent β€” ink indigo (not terracotta) */
--indigo: #3D3A8E;
--indigo-strong: #2E2B6E;
--indigo-tint: #ECEBF7;
/* Content-type semantics */
--scripture-fg: #7C3D0E; --scripture-bg: #FBEFE0; --scripture-bd: #F0DCC4;
--commentary-fg: #3D3A8E; --commentary-bg: #ECEBF7; --commentary-bd: #DAD7F0;
--qa-fg: #0E6F66; --qa-bg: #E2F1EE; --qa-bd: #C9E6E0;
--meditation-fg: #4D6B16; --meditation-bg: #EEF3DF; --meditation-bd: #DCE7C3;
--slide-fg: #3D3A8E; --slide-bg: #ECEBF7; --slide-bd: #DAD7F0;
--front-fg: #6A6675; --front-bg: #F4F3EF; --front-bd: #D8D4CB;
--danger: #B42318;
/* Typography */
--font-reading: "Spectral", Georgia, serif;
--font-ui: "Hanken Grotesk", system-ui, sans-serif;
--font-data: "IBM Plex Mono", ui-monospace, monospace;
/* Shadow */
--shadow-card: 0 1px 2px rgba(20,18,30,.05), 0 12px 28px -16px rgba(20,18,30,.12);
}
/* ── base ── */
html, body, [data-testid="stAppViewContainer"] {
background: var(--paper) !important;
font-family: var(--font-ui);
color: var(--ink);
}
/* hide default streamlit chrome */
#MainMenu, footer, header { visibility: hidden; }
[data-testid="stSidebar"] { display: none; }
[data-testid="stMainBlockContainer"] { padding-top: 0 !important; }
/* ── masthead β€” slim, quiet ── */
.masthead {
padding: 13px 32px;
display: flex;
align-items: baseline;
gap: 10px;
border-bottom: 1px solid var(--hairline);
background: var(--surface);
margin: -1rem -1rem 0 -1rem;
}
.masthead .mark {
font-size: 1.05em;
line-height: 1;
}
.masthead .wordmark {
font-family: var(--font-ui);
font-weight: 600;
font-size: 1.0em;
color: var(--ink);
letter-spacing: -0.01em;
}
.masthead .holdings {
font-family: var(--font-ui);
font-size: 0.76em;
color: var(--ink-muted);
font-weight: 400;
margin-left: auto;
letter-spacing: 0;
}
/* ── chat bubbles (interim β€” replaced in Β§8.2) ── */
[data-testid="stChatMessage"] { background: transparent !important; }
[data-testid="stChatMessageContent"] p {
font-family: var(--font-reading);
color: var(--ink);
line-height: 1.75;
font-size: 0.97em;
}
/* user bubble */
[data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"])
[data-testid="stChatMessageContent"] {
background: var(--indigo-tint) !important;
border: 1px solid var(--commentary-bd);
border-radius: 4px 18px 18px 18px;
padding: 13px 17px;
}
[data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"])
[data-testid="stChatMessageContent"] p {
color: var(--indigo-strong) !important;
}
/* assistant bubble */
[data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarAssistant"])
[data-testid="stChatMessageContent"] {
background: var(--surface) !important;
border: 1px solid var(--hairline);
border-radius: 4px 18px 18px 18px;
padding: 14px 18px;
box-shadow: 0 1px 3px rgba(20,18,30,.05), 0 4px 12px rgba(20,18,30,.04);
}
/* ── Β§8.1 control row + slider fix ── */
/* Selectboxes */
[data-testid="stSelectbox"] > div > div {
background: var(--surface) !important;
border: 1px solid var(--hairline-strong) !important;
border-radius: 8px !important;
font-family: var(--font-ui) !important;
font-size: 0.85em !important;
color: var(--ink) !important;
}
/* Slider thumb β€” already indigo, reinforce */
[role="slider"] {
background: var(--indigo) !important;
border-color: var(--indigo) !important;
box-shadow: 0 0 0 3px rgba(61,58,142,.15) !important;
}
/* Track fill: Streamlit sets the fill colour as an inline style
(rgb(255,75,75)). Target it directly β€” a stylesheet !important rule
overrides inline styles, and this survives Streamlit class renames. */
[data-baseweb="slider"] div[style*="rgb(255, 75, 75)"] {
background: var(--indigo) !important;
background-image: none !important;
}
/* Thumb value label color: rgb(255,75,75) β†’ indigo */
[data-testid="stSliderThumbValue"] p,
[data-testid="stSliderThumbValue"] {
color: var(--indigo) !important;
font-family: var(--font-data) !important;
font-size: 0.78em !important;
}
[data-testid="stSliderTickBar"] p {
font-family: var(--font-data) !important;
font-size: 0.72em !important;
color: var(--ink-muted) !important;
}
/* Caption text under slider */
[data-testid="stCaptionContainer"] p {
font-family: var(--font-ui) !important;
font-size: 0.78em !important;
color: var(--ink-muted) !important;
}
/* Toggle */
[data-testid="stToggle"] label {
font-family: var(--font-ui) !important;
font-size: 0.85em !important;
color: var(--ink-muted) !important;
}
/* Button */
[data-testid="stBaseButton-secondary"] {
font-family: var(--font-ui) !important;
font-size: 0.85em !important;
border-color: var(--hairline-strong) !important;
color: var(--ink-muted) !important;
background: var(--surface) !important;
border-radius: 8px !important;
}
[data-testid="stBaseButton-secondary"]:hover {
border-color: var(--indigo) !important;
color: var(--indigo) !important;
}
/* ── source panel ── */
.src-panel {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 16px;
box-shadow: var(--shadow-card);
}
.src-panel h4 {
font-family: var(--font-ui);
color: var(--ink-muted);
font-size: 0.68em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 12px 0;
}
/* ── source cards ── */
.src-card {
background: var(--paper);
border: 1px solid var(--hairline);
border-left: 3px solid var(--hairline-strong);
border-radius: 0 10px 10px 0;
padding: 10px 13px;
margin-bottom: 8px;
transition: box-shadow 0.15s, background 0.15s;
}
.src-card:hover {
box-shadow: 0 2px 8px rgba(20,18,30,.08);
background: var(--surface);
}
/* type-keyed left borders */
.src-card[data-ct="scripture"] { border-left-color: var(--scripture-bd); }
.src-card[data-ct="commentary"] { border-left-color: var(--commentary-bd); }
.src-card[data-ct="lecture_commentary"] { border-left-color: var(--qa-bd); }
.src-card[data-ct="lecture_qa"] { border-left-color: var(--qa-bd); }
.src-card[data-ct="guided_meditation"] { border-left-color: var(--meditation-bd); }
.src-card[data-ct="slide_content"] { border-left-color: var(--slide-bd); }
.src-card .src-title {
font-family: var(--font-ui);
font-size: 0.81em;
font-weight: 600;
color: var(--ink);
line-height: 1.45;
}
.src-card .src-meta {
font-family: var(--font-data);
font-size: 0.70em;
color: var(--ink-muted);
margin-top: 3px;
}
.src-card .src-excerpt {
font-family: var(--font-reading);
font-size: 0.78em;
color: var(--ink-muted);
margin-top: 7px;
line-height: 1.6;
border-top: 1px solid var(--hairline);
padding-top: 7px;
}
.src-card a { color: var(--indigo); text-decoration: underline; }
.src-card a:hover { color: var(--indigo-strong); }
/* ── badges ── */
.badge {
display: inline-block;
padding: 2px 7px;
border-radius: 6px;
font-family: var(--font-ui);
font-size: 0.64em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-right: 5px;
}
.b-scripture { background: var(--scripture-bg); color: var(--scripture-fg); }
.b-commentary { background: var(--commentary-bg); color: var(--commentary-fg); }
.b-slide { background: var(--slide-bg); color: var(--slide-fg); }
.b-lecture { background: var(--qa-bg); color: var(--qa-fg); }
.b-qa { background: var(--qa-bg); color: var(--qa-fg); }
.b-meditation { background: var(--meditation-bg); color: var(--meditation-fg); }
.b-front { background: var(--front-bg); color: var(--front-fg); }
/* ── empty state ── */
.empty-state {
text-align: center;
padding: 72px 24px;
color: var(--ink-muted);
}
.empty-state .icon { font-size: 2.4em; margin-bottom: 16px; }
.empty-state h3 {
font-family: var(--font-reading);
color: var(--ink);
font-size: 1.15em;
font-weight: 400;
font-style: italic;
margin-bottom: 8px;
}
.empty-state p {
font-family: var(--font-ui);
font-size: 0.85em;
line-height: 1.7;
color: var(--ink-muted);
}
.suggestion {
display: inline-block;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 20px;
padding: 6px 14px;
margin: 4px;
font-family: var(--font-ui);
font-size: 0.80em;
color: var(--ink);
font-weight: 500;
box-shadow: 0 1px 2px rgba(20,18,30,.05);
}
/* ── chat input ── */
[data-testid="stChatInput"] textarea {
background: var(--surface) !important;
border: 1.5px solid var(--hairline-strong) !important;
border-radius: 10px !important;
color: var(--ink) !important;
font-family: var(--font-reading) !important;
font-size: 0.95em !important;
}
[data-testid="stChatInput"] textarea::placeholder {
font-style: italic;
color: var(--ink-muted) !important;
}
[data-testid="stChatInput"] textarea:focus {
border-color: var(--indigo) !important;
box-shadow: 0 0 0 3px rgba(61,58,142,.12) !important;
}
/* ── cached badge ── */
.cached-badge {
display: inline-block;
background: var(--qa-bg);
color: var(--qa-fg);
font-family: var(--font-data);
font-size: 0.62em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 20px;
margin-left: 6px;
vertical-align: middle;
}
/* ── token count ── */
.token-count {
font-family: var(--font-data);
font-size: 0.68em;
color: var(--hairline-strong);
text-align: right;
margin-top: 4px;
}
/* ── Β§8.2 reading column ── */
.reading-col {
width: 100%;
}
/* question β€” indigo rule + Spectral 21px */
.q-block {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.q-rule {
color: var(--indigo);
font-size: 1.25em;
line-height: 1.4;
flex-shrink: 0;
margin-top: 2px;
user-select: none;
}
.q-text {
font-family: var(--font-reading);
font-size: 1.22em;
color: var(--ink);
line-height: 1.4;
font-weight: 400;
}
/* answer β€” surface card, Spectral 17px/1.7 */
.a-surface {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 20px 24px;
box-shadow: var(--shadow-card);
margin-bottom: 6px;
}
.a-surface p, .a-surface li {
font-family: var(--font-reading);
font-size: 1.0625rem;
line-height: 1.7;
color: var(--ink);
margin: 0 0 0.85em 0;
}
.a-surface p:last-child, .a-surface li:last-child { margin-bottom: 0; }
.a-surface ul, .a-surface ol { padding-left: 1.5em; margin: 0 0 0.85em 0; }
.a-surface strong { font-weight: 600; }
.a-surface em { font-style: italic; }
.a-surface h3, .a-surface h4 {
font-family: var(--font-ui);
font-size: 0.76rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
margin: 1.4em 0 0.5em 0;
}
/* citation chips β€” superscript number in indigo */
a.cite-chip {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-data);
font-size: 0.70em;
font-weight: 500;
color: var(--indigo);
background: var(--indigo-tint);
border: 1px solid var(--commentary-bd);
border-radius: 8px;
padding: 1px 5px;
vertical-align: super;
line-height: 1;
cursor: pointer;
text-decoration: none;
margin: 0 1px;
transition: background 0.12s, color 0.12s;
}
a.cite-chip:hover {
background: var(--commentary-bd);
color: var(--indigo-strong);
text-decoration: none;
}
.qa-spacer { height: 36px; }
/* ── Β§3 scripture recitation blocks ── */
.a-surface blockquote {
margin: 1.2em 0;
padding: 14px 18px 14px 16px;
background: var(--scripture-bg);
border-left: 3px solid var(--scripture-fg);
border-radius: 0 8px 8px 0;
}
.a-surface blockquote p {
font-family: var(--font-reading);
font-size: 1.0em;
font-style: italic;
color: var(--scripture-fg);
line-height: 1.75;
margin: 0 0 0.4em 0;
}
.a-surface blockquote p:last-child { margin-bottom: 0; }
/* sutta ref line (starts with "β€” ") */
.a-surface blockquote p:last-child:has(em) {
font-style: normal;
font-family: var(--font-data);
font-size: 0.72em;
color: var(--scripture-fg);
opacity: 0.8;
margin-top: 6px;
}
/* ── Β§4 citation ↔ source binding ── */
.src-card {
transition: box-shadow 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.src-card.src-active {
box-shadow: 0 0 0 2px var(--indigo), 0 4px 20px rgba(20,18,30,.14) !important;
background: var(--surface) !important;
transform: translateY(-2px);
}
a.cite-chip.chip-active {
background: var(--commentary-bd);
color: var(--indigo-strong);
box-shadow: 0 0 0 2px var(--indigo-tint);
}
/* ── Β§5 decline / not-covered state ── */
.decline-block {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 28px 24px;
box-shadow: var(--shadow-card);
text-align: center;
}
.decline-block p {
font-family: var(--font-reading);
font-size: 1.0em;
line-height: 1.7;
color: var(--ink-muted);
margin: 0;
font-style: italic;
}
/* ── responsive layout ── */
/* Source rail: sticky on desktop so it stays visible while reading */
[data-testid="stColumn"]:last-child > div {
position: sticky;
top: 16px;
}
/* Mobile: single column β€” sources collapse below reading column */
@media (max-width: 768px) {
[data-testid="stHorizontalBlock"] {
flex-direction: column !important;
}
[data-testid="stColumn"] {
width: 100% !important;
flex: none !important;
min-width: 100% !important;
}
[data-testid="stColumn"]:last-child > div {
position: static;
}
.masthead {
padding: 12px 16px;
}
.masthead .holdings {
display: none;
}
[data-testid="stMainBlockContainer"] {
padding-left: 12px !important;
padding-right: 12px !important;
}
.a-surface {
padding: 16px;
}
ctrl1, ctrl2, ctrl3 {
flex: none;
width: 100%;
}
}
/* ── Β§3 source rail β€” structured apparatus cards ── */
.src-card {
background: var(--paper);
border: 1px solid var(--hairline);
border-left: 3px solid var(--hairline-strong);
border-radius: 0 10px 10px 0;
padding: 10px 13px 10px 10px;
margin-bottom: 8px;
transition: box-shadow 0.15s, background 0.15s;
display: grid;
grid-template-columns: 32px 1fr;
gap: 0 10px;
}
.src-card[data-ct="scripture"] { border-left-color: var(--scripture-bd); }
.src-card[data-ct="commentary"] { border-left-color: var(--commentary-bd); }
.src-card[data-ct="lecture_commentary"] { border-left-color: var(--qa-bd); }
.src-card[data-ct="lecture_qa"] { border-left-color: var(--qa-bd); }
.src-card[data-ct="guided_meditation"] { border-left-color: var(--meditation-bd); }
.src-card[data-ct="slide_content"] { border-left-color: var(--slide-bd); }
.src-card:hover { box-shadow: 0 2px 8px rgba(20,18,30,.08); background: var(--surface); }
/* number anchor */
.src-num {
font-family: var(--font-data);
font-size: 1.0em;
font-weight: 500;
color: var(--indigo);
line-height: 1.2;
padding-top: 2px;
}
/* right-side content */
.src-body {}
.src-type-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3px;
}
/* score β€” muted mono, right-aligned */
.src-score {
font-family: var(--font-data);
font-size: 0.65em;
color: var(--ink-muted);
opacity: 0.7;
}
/* structured source lines */
.src-line1 {
font-family: var(--font-ui);
font-size: 0.78em;
font-weight: 600;
color: var(--ink);
line-height: 1.35;
}
.src-line1 a { color: var(--ink); text-decoration: none; }
.src-line1 a:hover { color: var(--indigo); text-decoration: underline; }
.src-line2 {
font-family: var(--font-data);
font-size: 0.67em;
color: var(--ink-muted);
margin-top: 2px;
line-height: 1.35;
}
.src-excerpt {
font-family: var(--font-reading);
font-size: 0.76em;
color: var(--ink-muted);
margin-top: 7px;
line-height: 1.55;
border-top: 1px solid var(--hairline);
padding-top: 7px;
grid-column: 1 / -1;
}
/* ── Streamlit tabs ── */
[data-testid="stTabs"] [role="tablist"] {
border-bottom: 1px solid var(--hairline) !important;
gap: 4px;
}
[data-testid="stTabs"] [role="tab"] {
font-family: var(--font-ui) !important;
font-size: 0.85em !important;
font-weight: 500 !important;
color: var(--ink-muted) !important;
border-radius: 6px 6px 0 0 !important;
padding: 8px 16px !important;
border: none !important;
background: transparent !important;
}
[data-testid="stTabs"] [role="tab"][aria-selected="true"] {
color: var(--indigo) !important;
border-bottom: 2px solid var(--indigo) !important;
background: transparent !important;
}
[data-testid="stTabs"] [role="tab"]:hover {
color: var(--ink) !important;
background: var(--surface) !important;
}
/* ── FAQ search input ── */
[data-testid="stTextInput"] input {
background: var(--surface) !important;
border: 1.5px solid var(--hairline-strong) !important;
border-radius: 10px !important;
color: var(--ink) !important;
font-family: var(--font-ui) !important;
font-size: 0.88em !important;
padding: 9px 14px !important;
}
[data-testid="stTextInput"] input::placeholder {
font-style: italic;
color: var(--ink-muted) !important;
}
[data-testid="stTextInput"] input:focus {
border-color: var(--indigo) !important;
box-shadow: 0 0 0 3px rgba(61,58,142,.12) !important;
outline: none !important;
}
/* ── FAQ group headers ── */
.faq-group-header {
font-family: var(--font-ui);
font-size: 0.68em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink-muted);
padding: 20px 0 8px 0;
border-bottom: 1px solid var(--hairline);
margin-bottom: 8px;
}
.faq-group-header:first-child { padding-top: 4px; }
.faq-empty {
font-family: var(--font-ui);
font-size: 0.85em;
color: var(--ink-muted);
padding: 32px 0;
text-align: center;
}
/* ── FAQ topic pills (st.radio horizontal) ── */
[data-testid="stExpander"] ~ * { /* spacing safeguard */ }
div[role="radiogroup"] {
gap: 6px !important;
flex-wrap: wrap !important;
margin-bottom: 6px;
}
div[role="radiogroup"] label {
background: var(--surface) !important;
border: 1px solid var(--hairline) !important;
border-radius: 20px !important;
padding: 5px 13px !important;
margin: 0 !important;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
div[role="radiogroup"] label:hover {
border-color: var(--qa-fg) !important;
}
/* hide the actual radio dot */
div[role="radiogroup"] label > div:first-child {
display: none !important;
}
div[role="radiogroup"] label p {
font-family: var(--font-ui) !important;
font-size: 0.78em !important;
color: var(--ink-muted) !important;
font-weight: 500 !important;
}
/* selected pill */
div[role="radiogroup"] label:has(input:checked) {
background: var(--qa-bg) !important;
border-color: var(--qa-bd) !important;
}
div[role="radiogroup"] label:has(input:checked) p {
color: var(--qa-fg) !important;
font-weight: 600 !important;
}
/* ── st.expander styling for FAQ ── */
[data-testid="stExpander"] {
border: 1px solid var(--hairline) !important;
border-left: 3px solid var(--qa-bd) !important;
border-radius: 0 10px 10px 0 !important;
background: var(--paper) !important;
box-shadow: none !important;
margin-bottom: 5px !important;
}
[data-testid="stExpander"]:has(details[open]) {
background: var(--surface) !important;
box-shadow: var(--shadow-card) !important;
}
[data-testid="stExpander"] summary {
font-family: var(--font-reading) !important;
font-size: 0.96em !important;
color: var(--ink) !important;
min-height: 44px !important;
align-items: center !important;
}
[data-testid="stExpander"] summary p {
font-family: var(--font-reading) !important;
font-size: 0.96em !important;
color: var(--ink) !important;
line-height: 1.45 !important;
}
[data-testid="stExpanderToggleIcon"] svg {
color: var(--qa-fg) !important;
fill: var(--qa-fg) !important;
}
/* answer body inside expander */
[data-testid="stExpander"] [data-testid="stExpanderDetails"] p {
font-family: var(--font-reading) !important;
font-size: 0.94em !important;
line-height: 1.75 !important;
color: var(--ink) !important;
}
[data-testid="stExpander"] [data-testid="stExpanderDetails"] em {
font-style: italic;
color: var(--ink-muted) !important;
font-size: 0.88em !important;
display: block;
margin-bottom: 8px;
}
[data-testid="stExpander"] hr {
border-color: var(--hairline) !important;
margin: 6px 0 12px 0 !important;
}
</style>
""", unsafe_allow_html=True)
# ── FAQ data ─────────────────────────────────────────────────────────────────
import hashlib as _hashlib
import re as _re
from collections import defaultdict as _defaultdict
def _q_hash(question: str) -> str:
"""Stable topic-cache key β€” must match classify_faq_topics.py:q_hash."""
normalized = " ".join(question.lower().split())
return _hashlib.sha256(normalized.encode()).hexdigest()[:16]
_TOPIC_ORDER = [
"Meditation Practice",
"Craving & Desire",
"Anger & Difficult Emotions",
"Relationships & Others",
"Fear & Anxiety",
"Enlightenment & The Path",
"Kamma & Rebirth",
"Non-Self & The Mind",
"Daily Life & Practice",
"Other",
]
@st.cache_data
def _load_faq_data() -> list[tuple[str, list[tuple[str, str]]]]:
"""Load lecture_qa chunks grouped by LLM-assigned topic.
Falls back to chapter-based grouping if faq_topics.json not yet generated.
Returns list of (topic_label, [(q_full, a), ...])."""
chunks_path = Path("data/glp/lecture_chunks.jsonl")
topics_path = Path("data/glp/faq_topics.json")
if not chunks_path.exists():
return []
chunks = [json.loads(l) for l in chunks_path.open(encoding="utf-8") if l.strip()]
qa = [c for c in chunks if c.get("content_type") == "lecture_qa"]
def _parse_qa(text: str) -> tuple[str, str]:
if "\nA:" in text:
q_raw, a_raw = text.split("\nA:", 1)
return q_raw.replace("Q:", "", 1).strip(), a_raw.strip()
return text[:200], text
# ── topic-based grouping (LLM-classified, keyed by question hash) ─────────
if topics_path.exists():
topic_map: dict[str, str] = json.loads(topics_path.read_text())
groups: dict[str, list] = _defaultdict(list)
for c in qa:
q, a = _parse_qa(c["text"])
topic = topic_map.get(_q_hash(q), "Other")
groups[topic].append((q, a))
return [
(t, groups[t]) for t in _TOPIC_ORDER if t in groups
] + [(t, groups[t]) for t in sorted(groups) if t not in _TOPIC_ORDER]
# ── fallback: chapter-based grouping ─────────────────────────────────────
def _sort_key(sf: str) -> tuple:
m = _re.match(r"GLP-Ch(\d+)-", sf)
return (int(m.group(1)), sf) if m else (0, sf)
def _group_key(sf: str) -> str:
m = _re.match(r"(GLP-Ch\d+-[^_]+)_[A-Za-z0-9_-]{10,}\.txt$", sf)
return m.group(1) if m else _re.sub(r"_[A-Za-z0-9_-]{10,}\.txt$", "", sf)
def _display_label(gkey: str) -> str:
m = _re.match(r"GLP-Ch\d+-(.+)", gkey)
if m:
return m.group(1).replace("-", " ")
return _re.sub(r"^GLP-", "", gkey).replace("-", " ")
ch_groups: dict[str, list] = _defaultdict(list)
sort_keys: dict[str, tuple] = {}
for c in qa:
sf = Path(c["source_file"]).name
gk = _group_key(sf)
ch_groups[gk].append(_parse_qa(c["text"]))
if gk not in sort_keys:
sort_keys[gk] = _sort_key(sf)
return [
(_display_label(gk), ch_groups[gk])
for gk in sorted(ch_groups, key=lambda k: sort_keys[k])
]
@st.cache_data(show_spinner=False)
def _faq_semantic_search(query: str, top_n: int = 30) -> list[tuple[str, str, str]]:
"""Semantic (ANN) search over lecture_qa chunks via the shared ChromaDB
embedding index. Returns ranked [(question, answer, topic), ...]."""
import numpy as _np
from retrieval import _model, _collection
topics_path = Path("data/glp/faq_topics.json")
topic_map = json.loads(topics_path.read_text()) if topics_path.exists() else {}
embedding = _np.array(next(iter(_model().embed([query])))).tolist()
res = _collection().query(
query_embeddings=[embedding],
n_results=top_n,
where={"content_type": {"$eq": "lecture_qa"}},
include=["documents"],
)
out: list[tuple[str, str, str]] = []
for doc in res["documents"][0]:
if "\nA:" in doc:
q_raw, a_raw = doc.split("\nA:", 1)
q, a = q_raw.replace("Q:", "", 1).strip(), a_raw.strip()
else:
q, a = doc[:200], doc
topic = topic_map.get(_q_hash(q), "Other")
out.append((q, a, topic))
return out
# PREAMBLE_RE strips the rambling opener before the real question
_PREAMBLE_RE = _re.compile(
r"^(?:(?:okay|ok|well said|thank you|thanks|hi|hello|"
r"yes|yeah|yep|alright|right|sure|great|good|so|um+|uh+|"
r"ah+|er+|i see|i mean)[,\.]?\s*)+",
_re.IGNORECASE,
)
def _q_title(q: str, max_len: int = 115) -> str:
"""Return a clean, scannable question title."""
cleaned = _PREAMBLE_RE.sub("", q).strip()
if cleaned:
cleaned = cleaned[0].upper() + cleaned[1:]
else:
cleaned = q.strip()
if len(cleaned) > max_len:
cut = cleaned[:max_len].rsplit(" ", 1)[0]
return cut.rstrip(",.") + "…"
return cleaned
# ── top bar ───────────────────────────────────────────────────────────────────
st.markdown("""
<div class="masthead">
<span class="mark">✿</span>
<span class="wordmark">Teachings Q&amp;A</span>
<span class="holdings">David Roylance Β· 13 books Β· 6 presentations Β· 85 lectures</span>
</div>
""", unsafe_allow_html=True)
# ── session state ─────────────────────────────────────────────────────────────
if "messages" not in st.session_state:
st.session_state.messages = _load_history()
if "pending_sources" not in st.session_state:
st.session_state.pending_sources = []
# ── tabs ─────────────────────────────────────────────────────────────────────
tab_qa, tab_faq = st.tabs(["Q&A", "From the Sessions"])
BADGE = {
"scripture": ("Scripture", "b-scripture"),
"commentary": ("Commentary", "b-commentary"),
"book_front_matter": ("Front matter","b-front"),
"slide_content": ("Slide", "b-slide"),
"lecture_commentary": ("Lecture", "b-lecture"),
"lecture_qa": ("Q&A", "b-qa"),
"guided_meditation": ("Meditation", "b-meditation"),
}
SUGGESTIONS = [
"What is craving?",
"How does one attain stream-entry?",
"What is the role of a teacher?",
"Explain the Five Precepts",
"What is the purpose of meditation?",
]
def linkify_citations(text: str, sources: list[dict]) -> str:
"""Replace [Source N] patterns with inline superscript citation chips."""
url_map = {s["index"]: s.get("source_url", "") for s in sources}
def _replace(m: re.Match) -> str:
nums = [int(n) for n in re.findall(r"\d+", m.group(0))]
parts = []
for n in nums:
url = url_map.get(n, "")
href = url if url else f"#src-{n}"
target = ' target="_blank"' if url else ""
parts.append(
f'<a class="cite-chip" href="{href}"{target} data-ref="{n}">{n}</a>'
)
return "".join(parts)
return re.sub(r"\[(?:Source \d+)(?:,\s*(?:Source )?\d+)*\]", _replace, text)
TYPE_LABEL = {
"scripture": "Scripture",
"commentary": "Commentary",
"book_front_matter": "Front Matter",
"slide_content": "Slide",
"lecture_commentary": "Lecture",
"lecture_qa": "Q&amp;A",
"guided_meditation": "Meditation",
}
def _card_lines(s: dict) -> tuple[str, str]:
"""Return (line1, line2) for a source card's structured metadata."""
ct = s["content_type"]
label = s["source_label"].replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
vol_num = s.get("vol_num") or 0
vol_title = (s.get("vol_title") or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
ch_num = s.get("ch_num") or 0
ch_title = (s.get("ch_title") or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
page = s.get("page") or 0
refs = " Β· ".join(s["sutta_refs"]) if s["sutta_refs"] else ""
if ct in ("scripture", "commentary", "book_front_matter"):
line1 = vol_title or label
parts2 = []
if vol_num:
parts2.append(f"Vol {vol_num}")
if ch_num:
parts2.append(f"Ch {ch_num}")
if ch_title:
parts2.append(ch_title[:50])
if page:
parts2.append(f"p. {page}")
if refs:
parts2.append(refs)
line2 = " Β· ".join(parts2)
elif ct == "slide_content":
line1 = label
line2 = f"p. {page}" if page else ""
else:
# lectures
line1 = label
line2 = refs if refs else ""
return line1, line2
def render_source_cards(sources: list[dict], show_excerpts: bool) -> str:
html = '<div class="src-panel"><h4>Sources</h4>'
for s in sources:
ct = s["content_type"]
_, badge_cls = BADGE.get(ct, (ct, "b-front"))
type_lbl = TYPE_LABEL.get(ct, ct)
url = s.get("source_url", "")
idx = s["index"]
score = s["score"]
line1, line2 = _card_lines(s)
line1_html = (
f'<a href="{url}" target="_blank">{line1}</a>' if url else line1
)
excerpt = ""
if show_excerpts:
txt = s["text"][:280].replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", " ")
if len(s["text"]) > 280:
txt += "…"
excerpt = f'<div class="src-excerpt">{txt}</div>'
html += f"""
<div class="src-card" data-ct="{ct}" id="src-{idx}">
<div class="src-num">[{idx}]</div>
<div class="src-body">
<div class="src-type-row">
<span class="badge {badge_cls}">{type_lbl}</span>
<span class="src-score">{score}</span>
</div>
<div class="src-line1">{line1_html}</div>
{f'<div class="src-line2">{line2}</div>' if line2 else ''}
{excerpt}
</div>
</div>"""
html += "</div>"
return html
def render_qa_thread(messages: list[dict]) -> str:
"""Render the full Q&A thread as a reading-column HTML block."""
html = '<div class="reading-col">'
for msg in messages:
if msg["role"] == "user":
q = msg["content"].replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html += f'''
<div class="q-block">
<span class="q-rule">▍</span>
<span class="q-text">{q}</span>
</div>'''
else:
sources = msg.get("sources") or []
content = msg["content"]
usage = msg.get("usage") or {}
cached_badge = '<span class="cached-badge">cached</span>' if msg.get("cached") else ""
token_line = ""
if usage:
token_line = (
f'<div class="token-count">'
f'{usage.get("input","")} in Β· {usage.get("output","")} out'
f'{cached_badge}</div>'
)
DECLINE_TRIGGER = "The source passages here don't cover that."
if content.strip().startswith(DECLINE_TRIGGER):
safe = content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html += f'<div class="decline-block"><p>{safe}</p></div>'
else:
answer_with_chips = linkify_citations(content, sources)
answer_html = _md.markdown(answer_with_chips, extensions=["nl2br"])
html += f'<div class="a-surface">{answer_html}</div>'
html += f'{token_line}<div class="qa-spacer"></div>'
html += '</div>'
return html
# ═══════════════════════════════════════════════════════════════════════════════
# TAB 1 β€” Q&A
# ═══════════════════════════════════════════════════════════════════════════════
with tab_qa:
st.markdown("<div style='height:12px'></div>", unsafe_allow_html=True)
# ── controls row ──────────────────────────────────────────────────────────
ctrl1, ctrl2, ctrl3, ctrl4 = st.columns([2, 2, 2, 1])
with ctrl1:
n_results = st.select_slider(
"Sources", options=[3, 4, 5, 6, 8, 10, 12, 14, 16, 18], value=14,
label_visibility="collapsed",
help="Number of sources to retrieve"
)
st.caption(f"Retrieving {n_results} sources")
with ctrl2:
type_options = {
"All types": None,
"Scripture": "scripture",
"Commentary": "commentary",
"Lecture": "lecture_commentary",
"Q&A": "lecture_qa",
"Slide": "slide_content",
"Meditation": "guided_meditation",
}
type_label = st.selectbox("Type", list(type_options.keys()),
label_visibility="collapsed")
content_type = type_options[type_label]
with ctrl3:
vol_options = {"All volumes": 0} | {f"Vol {i}": i for i in range(1, 14)}
vol_label = st.selectbox("Volume", list(vol_options.keys()),
label_visibility="collapsed")
volume = vol_options[vol_label] or None
with ctrl4:
show_excerpts = st.toggle("Excerpts", value=False)
if st.button("Clear", use_container_width=True):
st.session_state.messages = []
st.session_state.pending_sources = []
HISTORY_FILE.unlink(missing_ok=True)
st.rerun()
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
# ── two-column layout ─────────────────────────────────────────────────────
chat_col, src_col = st.columns([5, 2], gap="large")
# ── chat column ───────────────────────────────────────────────────────────
with chat_col:
if not st.session_state.messages:
st.markdown("""
<div class="empty-state">
<div class="icon">✿</div>
<h3>Ask a question about the Teachings.</h3>
<p>Answers drawn from David Roylance's books,<br>retreat presentations, and class transcripts.</p>
<div style="margin-top:16px">
<span class="suggestion">What is craving?</span>
<span class="suggestion">How does one attain stream-entry?</span>
<span class="suggestion">What is the role of a teacher?</span>
<span class="suggestion">Explain the Five Precepts</span>
</div>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(render_qa_thread(st.session_state.messages), unsafe_allow_html=True)
if question := st.chat_input("Ask a question…"):
st.session_state.messages.append({"role": "user", "content": question})
with st.spinner(""):
try:
result = get_answer(
question,
n_results=n_results,
content_type=content_type,
volume=volume,
)
st.session_state.messages.append({
"role": "assistant",
"content": result["answer"],
"sources": result["sources"],
"usage": result["usage"],
"cached": result.get("cached", False),
})
st.session_state.pending_sources = result["sources"]
_save_history(st.session_state.messages)
except Exception as e:
st.error(f"Error: {e}")
st.rerun()
# ── sources column ────────────────────────────────────────────────────────
with src_col:
latest_sources = None
for msg in reversed(st.session_state.messages):
if msg["role"] == "assistant" and msg.get("sources"):
latest_sources = msg["sources"]
break
if latest_sources:
st.markdown(
render_source_cards(latest_sources, show_excerpts),
unsafe_allow_html=True,
)
else:
st.markdown("""
<div class="src-panel" style="text-align:center; padding:48px 16px">
<div style="font-size:1.8em; margin-bottom:14px; opacity:0.4">πŸ“Ž</div>
<div style="color:var(--ink-muted); font-size:0.82em; font-family:var(--font-ui); line-height:1.6">
Sources from the retrieved passages<br>will appear here
</div>
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════════════════════════
# TAB 2 β€” FROM THE SESSIONS (FAQ)
# ═══════════════════════════════════════════════════════════════════════════════
with tab_faq:
st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
_faq_col, _ = st.columns([5, 2], gap="large")
with _faq_col:
_faq_data = _load_faq_data()
# ── topic selector (with counts) ──────────────────────────────────────
_topic_labels = ["All topics"] + [
f"{label} ({len(pairs)})" for label, pairs in _faq_data
]
_label_to_topic = {
f"{label} ({len(pairs)})": label for label, pairs in _faq_data
}
_picked = st.radio(
"Topic", _topic_labels, horizontal=True,
label_visibility="collapsed", key="faq_topic",
)
_selected_topic = _label_to_topic.get(_picked) # None = all
_faq_search = st.text_input(
"Search questions",
placeholder="Search by meaning (e.g. 'dealing with a difficult spouse')…",
label_visibility="collapsed",
key="faq_search",
)
st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True)
def _render_qa(q: str, a: str) -> None:
title = _q_title(q)
with st.expander(title):
if q.strip().lower() != title.lower().rstrip("…"):
st.markdown(f"*{q}*")
st.markdown("---")
paras = [p.strip() for p in a.split("\n\n") if p.strip()]
for p in (paras or [a]):
st.markdown(p)
_query = _faq_search.strip()
if _query:
# ── semantic (ANN) search ─────────────────────────────────────────
results = _faq_semantic_search(_query)
if _selected_topic:
results = [(q, a, t) for q, a, t in results if t == _selected_topic]
if results:
scope = f" in {_selected_topic}" if _selected_topic else ""
st.markdown(
f'<div class="faq-group-header">Closest matches{scope}</div>',
unsafe_allow_html=True,
)
for _q, _a, _t in results:
_render_qa(_q, _a)
else:
st.markdown(
'<div class="faq-empty">No questions match that search.</div>',
unsafe_allow_html=True,
)
else:
# ── browse by topic ───────────────────────────────────────────────
for _group_label, _qa_pairs in _faq_data:
if _selected_topic and _group_label != _selected_topic:
continue
if not _selected_topic:
st.markdown(
f'<div class="faq-group-header">{_group_label}</div>',
unsafe_allow_html=True,
)
for _q, _a in _qa_pairs:
_render_qa(_q, _a)
# ── Β§4 citation binding (JS via parent frame) ─────────────────────────────────
_components.html("""
<script>
(function () {
var p = window.parent.document;
function bind() {
// chip β†’ card
p.querySelectorAll('a.cite-chip').forEach(function(chip) {
if (chip._bound) return;
chip._bound = true;
var ref = chip.dataset.ref;
chip.addEventListener('mouseenter', function() {
var card = p.getElementById('src-' + ref);
if (card) {
card.classList.add('src-active');
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
chip.addEventListener('mouseleave', function() {
p.querySelectorAll('.src-card.src-active').forEach(function(c) {
c.classList.remove('src-active');
});
});
});
// card β†’ chip
p.querySelectorAll('.src-card[id^="src-"]').forEach(function(card) {
if (card._bound) return;
card._bound = true;
var ref = card.id.replace('src-', '');
card.addEventListener('mouseenter', function() {
p.querySelectorAll('a.cite-chip[data-ref="' + ref + '"]').forEach(function(chip) {
chip.classList.add('chip-active');
});
});
card.addEventListener('mouseleave', function() {
p.querySelectorAll('a.cite-chip.chip-active').forEach(function(chip) {
chip.classList.remove('chip-active');
});
});
});
}
// Initial bind + re-bind when Streamlit re-renders
setTimeout(bind, 800);
var observer = new MutationObserver(function() {
clearTimeout(window._bindTimer);
window._bindTimer = setTimeout(bind, 400);
});
observer.observe(p.body, { subtree: true, childList: true });
})();
</script>
""", height=0)