""" 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("""

✿ Teachings Q&A

Enter the password to continue.

""", 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(""" """, 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("""
Teachings Q&A David Roylance · 13 books · 6 presentations · 85 lectures
""", 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'{n}' ) 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&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("&", "&").replace("<", "<").replace(">", ">") vol_num = s.get("vol_num") or 0 vol_title = (s.get("vol_title") or "").replace("&", "&").replace("<", "<").replace(">", ">") ch_num = s.get("ch_num") or 0 ch_title = (s.get("ch_title") or "").replace("&", "&").replace("<", "<").replace(">", ">") 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 = '

Sources

' 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'{line1}' if url else line1 ) excerpt = "" if show_excerpts: txt = s["text"][:280].replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", " ") if len(s["text"]) > 280: txt += "…" excerpt = f'
{txt}
' html += f"""
[{idx}]
{type_lbl} {score}
{line1_html}
{f'
{line2}
' if line2 else ''} {excerpt}
""" html += "
" return html def render_qa_thread(messages: list[dict]) -> str: """Render the full Q&A thread as a reading-column HTML block.""" html = '
' for msg in messages: if msg["role"] == "user": q = msg["content"].replace("&", "&").replace("<", "<").replace(">", ">") html += f'''
{q}
''' else: sources = msg.get("sources") or [] content = msg["content"] usage = msg.get("usage") or {} cached_badge = 'cached' if msg.get("cached") else "" token_line = "" if usage: token_line = ( f'
' f'{usage.get("input","")} in · {usage.get("output","")} out' f'{cached_badge}
' ) DECLINE_TRIGGER = "The source passages here don't cover that." if content.strip().startswith(DECLINE_TRIGGER): safe = content.replace("&", "&").replace("<", "<").replace(">", ">") html += f'

{safe}

' else: answer_with_chips = linkify_citations(content, sources) answer_html = _md.markdown(answer_with_chips, extensions=["nl2br"]) html += f'
{answer_html}
' html += f'{token_line}
' html += '
' return html # ═══════════════════════════════════════════════════════════════════════════════ # TAB 1 — Q&A # ═══════════════════════════════════════════════════════════════════════════════ with tab_qa: st.markdown("
", 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("
", 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("""

Ask a question about the Teachings.

Answers drawn from David Roylance's books,
retreat presentations, and class transcripts.

What is craving? How does one attain stream-entry? What is the role of a teacher? Explain the Five Precepts
""", 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("""
📎
Sources from the retrieved passages
will appear here
""", unsafe_allow_html=True) # ═══════════════════════════════════════════════════════════════════════════════ # TAB 2 — FROM THE SESSIONS (FAQ) # ═══════════════════════════════════════════════════════════════════════════════ with tab_faq: st.markdown("
", 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("
", 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'
Closest matches{scope}
', unsafe_allow_html=True, ) for _q, _a, _t in results: _render_qa(_q, _a) else: st.markdown( '
No questions match that search.
', 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'
{_group_label}
', unsafe_allow_html=True, ) for _q, _a in _qa_pairs: _render_qa(_q, _a) # ── §4 citation binding (JS via parent frame) ───────────────────────────────── _components.html(""" """, height=0)