Spaces:
Sleeping
Sleeping
| """ | |
| 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&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", | |
| ] | |
| 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]) | |
| ] | |
| 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&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&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 = '<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("&", "&").replace("<", "<").replace(">", ">").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("&", "&").replace("<", "<").replace(">", ">") | |
| 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("&", "&").replace("<", "<").replace(">", ">") | |
| 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) | |