Spaces:
Sleeping
Sleeping
| """ | |
| Health Marketing Compliance RAG — Streamlit Chat UI | |
| Conversational interface for NZ healthcare marketing compliance, scoped to | |
| complementary/alternative practitioners and supplement sellers. Supports | |
| follow-up questions with context from prior exchanges. | |
| """ | |
| import streamlit as st | |
| import time | |
| import sys | |
| import os | |
| from datetime import datetime | |
| # Setup paths before importing src modules | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "PageIndex")) | |
| # Bridge Streamlit secrets → env vars before src.config loads. Locally, .env | |
| # populates env vars; on hosted Streamlit (HF Spaces, Streamlit Cloud) the | |
| # secrets manager does. Wrapped because st.secrets raises if no secrets file | |
| # exists at all (the local-dev case). | |
| try: | |
| for _k, _v in st.secrets.items(): | |
| os.environ.setdefault(_k, str(_v)) | |
| except Exception: | |
| pass | |
| from src.pipeline import run_query, run_query_retrieval, run_query_stream | |
| from src.config import DOCUMENT_REGISTRY, MODEL, MODEL_DISPLAY_NAME | |
| from src.language import SUPPORTED_LANGUAGES | |
| from src import persistence | |
| # ── Export helpers ────────────────────────────────────────────────────────── | |
| def format_single_response(query: str, result: dict) -> str: | |
| """Format a single Q&A exchange as Markdown.""" | |
| now = datetime.now().strftime("%Y-%m-%d %H:%M") | |
| lines = [ | |
| f"---", | |
| f"date: {now}", | |
| f"model: {MODEL_DISPLAY_NAME}", | |
| f"type: health-marketing-compliance-response", | |
| f"---", | |
| f"", | |
| f"# {query}", | |
| f"", | |
| result.get("answer", ""), | |
| f"", | |
| ] | |
| if result.get("citations"): | |
| lines.append("## Sources") | |
| lines.append("") | |
| for c in result["citations"]: | |
| if c.get("source_url"): | |
| lines.append(f"- [{c['domain']} — {c['title']}]({c['source_url']})") | |
| else: | |
| lines.append(f"- {c['domain']} — {c['title']}") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def format_full_conversation() -> str: | |
| """Format the entire conversation as Markdown.""" | |
| now = datetime.now().strftime("%Y-%m-%d %H:%M") | |
| lines = [ | |
| f"---", | |
| f"date: {now}", | |
| f"model: {MODEL_DISPLAY_NAME}", | |
| f"type: health-marketing-compliance-conversation", | |
| f"---", | |
| f"", | |
| f"# Complementary Medicine Marketing Compliance Conversation", | |
| f"", | |
| ] | |
| for i, msg in enumerate(st.session_state.messages): | |
| if msg["role"] == "user": | |
| lines.append(f"## Q: {msg['content']}") | |
| lines.append("") | |
| else: | |
| lines.append(msg["content"]) | |
| lines.append("") | |
| # Add citations if available | |
| result = st.session_state.results.get(i) | |
| if result and result.get("citations"): | |
| lines.append("**Sources:**") | |
| for c in result["citations"]: | |
| if c.get("source_url"): | |
| lines.append(f"- [{c['domain']} — {c['title']}]({c['source_url']})") | |
| else: | |
| lines.append(f"- {c['domain']} — {c['title']}") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def format_saved_responses_aggregate(saved_list: list[dict]) -> str: | |
| """Format every saved response into a single Markdown document with | |
| clear delineation between Q&As (heading per question, --- separator). | |
| Used by the sidebar 'Download saved responses' button — produces the | |
| artefact for users who want to copy their curated answers out of the | |
| app (Obsidian, file share, etc.).""" | |
| now = datetime.now().strftime("%Y-%m-%d %H:%M") | |
| lines = [ | |
| f"---", | |
| f"date: {now}", | |
| f"model: {MODEL_DISPLAY_NAME}", | |
| f"type: health-marketing-compliance-saved-responses", | |
| f"count: {len(saved_list)}", | |
| f"---", | |
| f"", | |
| f"# Saved Responses", | |
| f"", | |
| f"*Exported {now}. {len(saved_list)} saved response(s) from this browser.*", | |
| f"", | |
| f"---", | |
| f"", | |
| ] | |
| for saved in saved_list: | |
| question = saved.get("question", "") | |
| # The saved record holds the single result directly (not nested in | |
| # a results dict) — see save_response in persistence.py. | |
| result = saved.get("result", {}) or {} | |
| answer = saved.get("answer") or result.get("answer", "") | |
| saved_at = saved.get("saved_at", "")[:16].replace("T", " ") | |
| lines.append(f"## {question}") | |
| lines.append("") | |
| lines.append(f"*Saved {saved_at}*") | |
| lines.append("") | |
| lines.append(answer) | |
| lines.append("") | |
| if result.get("citations"): | |
| lines.append("**Sources:**") | |
| for c in result["citations"]: | |
| if c.get("source_url"): | |
| lines.append(f"- [{c['domain']} — {c['title']}]({c['source_url']})") | |
| else: | |
| lines.append(f"- {c['domain']} — {c['title']}") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _timing_caption(result: dict) -> str: | |
| """Build the timing/stats caption line for a response.""" | |
| timing = result.get("timing", {}) | |
| domains = result.get("domains_searched", []) | |
| parts = [ | |
| f"{timing.get('total', 0):.0f}s", | |
| f"{result.get('sections_retrieved', 0)} sections", | |
| ", ".join(d.replace("_", " ").title() for d in domains), | |
| ] | |
| tokens = result.get("token_usage", {}).get("total_tokens", 0) | |
| if tokens > 0: | |
| parts.append(f"{tokens:,} tokens") | |
| return " · ".join(parts) | |
| def _render_citations(citations: list) -> None: | |
| """Render citations as an inline chip row + collapsible full detail. | |
| The chip row gives a glanceable "what was cited" answer at the bottom of | |
| the response. The expander preserves line-numbers and fully-qualified | |
| domain/title metadata for the user who wants to verify rigorously. | |
| """ | |
| if not citations: | |
| return | |
| # Section heading — renders as the standard violet h2 via our | |
| # [data-testid="stChatMessage"] h2 rule, matching "## Short answer" | |
| # and "## Other things to consider" elsewhere in the response. | |
| st.markdown("## Information we referenced in this answer") | |
| chips_html_parts = ['<div class="citation-row">'] | |
| for c in citations: | |
| title = (c.get("title") or "").strip() | |
| url = c.get("source_url") | |
| # Escape minimal HTML chars that might appear in legislation titles | |
| safe_title = title.replace("&", "&").replace("<", "<").replace(">", ">") | |
| if url: | |
| chips_html_parts.append( | |
| f'<a class="citation-chip" href="{url}" target="_blank" rel="noopener">{safe_title}</a>' | |
| ) | |
| else: | |
| chips_html_parts.append(f'<span class="citation-chip">{safe_title}</span>') | |
| chips_html_parts.append("</div>") | |
| st.markdown("".join(chips_html_parts), unsafe_allow_html=True) | |
| with st.expander("Full citation detail"): | |
| for c in citations: | |
| line_info = f" (line {c['line_num']})" if c.get("line_num") else "" | |
| if c.get("source_url"): | |
| st.markdown( | |
| f"- **[{c['index']}]** {c['domain']} — [{c['title']}]({c['source_url']}){line_info}" | |
| ) | |
| else: | |
| st.markdown(f"- **[{c['index']}]** {c['domain']} — {c['title']}{line_info}") | |
| def make_filename(text: str, prefix: str = "health-marketing") -> str: | |
| """Create a safe filename from query text.""" | |
| slug = text[:50].lower().strip() | |
| slug = "".join(c if c.isalnum() or c == " " else "" for c in slug) | |
| slug = slug.strip().replace(" ", "-") | |
| date = datetime.now().strftime("%Y%m%d") | |
| return f"{prefix}-{date}-{slug}.md" | |
| # ── Page config ────────────────────────────────────────────────────────────── | |
| st.set_page_config( | |
| page_title="Complementary Medicine Marketing Compliance", | |
| page_icon="🩺", | |
| layout="wide", | |
| ) | |
| # ── Visual theme (CSS) ────────────────────────────────────────────────────── | |
| # Instrument Sans via Google Fonts @import. Brand: web3/AI-style violet | |
| # system (#542FE8 primary, #1E3798 deep, #33177A accent, #EBEDF7 canvas). | |
| # Custom classes: .citation-chip, .citation-row, .sidebar-section, | |
| # .footer-quiet, .hero-title. Streamlit-internal selectors used sparingly | |
| # and flagged for revisit on Streamlit version upgrades. | |
| st.markdown( | |
| """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&display=swap'); | |
| /* ── Canvas — vertical violet→deep-violet linear gradient ────────── */ | |
| /* Source: Figma frame Oylz9fwzjRsKk7TqPxscS3, node 8:2. | |
| Top stop: #3500B0 (saturated indigo) | |
| Bottom stop: #0F0027 (very dark deep violet, near-black) | |
| Gradient lands at #0F0027 by 85% so the bottom band matches the | |
| chat-input frame's #0F0027 — no visible seam where they meet. */ | |
| [data-testid="stAppViewContainer"] { | |
| background: linear-gradient(to bottom, #3500B0 0%, #0F0027 85%, #0F0027 100%); | |
| background-attachment: fixed; | |
| } | |
| /* Streamlit's header strip — make transparent so gradient shows through */ | |
| [data-testid="stHeader"] { background: transparent; } | |
| /* Password input on the gate. Surface (rgba 0.06) lives ONLY on | |
| the outer [data-baseweb='input'] wrapper. The input element | |
| itself is transparent so we don't get a double-translucent | |
| composite (input element + wrapper) where the eye toggle | |
| button area only has one layer — that mismatch is what the | |
| user saw as a 'darker patch behind the eye'. Now everything | |
| sits on the same single rgba surface. */ | |
| [data-testid="stTextInput"] input, | |
| [data-testid="stTextInput"] input[type="password"] { | |
| color: #FFFFFF !important; | |
| background: transparent !important; | |
| background-color: transparent !important; | |
| caret-color: #FFFFFF !important; | |
| } | |
| [data-testid="stTextInput"] [data-baseweb="input"] { | |
| background: rgba(255, 255, 255, 0.06) !important; | |
| border-color: rgba(255, 255, 255, 0.18) !important; | |
| } | |
| [data-testid="stTextInputIcon"], | |
| [data-testid="stTextInputIcon"] *, | |
| [data-testid="stTextInputIcon"] svg { | |
| color: #FFFFFF !important; | |
| fill: #FFFFFF !important; | |
| } | |
| /* Password show/hide eye toggle. | |
| CRITICAL: Streamlit's theme applies a transition on | |
| [data-baseweb="base-input"]'s background-color. CSS transitions | |
| have higher cascade origin than author !important — so a static | |
| 'background: transparent !important' rule alone can't win | |
| because the transition keeps re-interpolating from the theme's | |
| secondaryBackgroundColor (rgb(21,19,31)) toward the resolved | |
| value, never settling. | |
| The fix is to KILL the transition with 'transition: none | |
| !important' on the base-input wrapper, AND set the static bg. | |
| Both lines are required. */ | |
| [data-testid="stTextInput"] [data-baseweb="input"] *, | |
| [data-testid="stTextInput"] [data-baseweb="input"] *::before, | |
| [data-testid="stTextInput"] [data-baseweb="input"] *::after { | |
| background: transparent !important; | |
| background-color: transparent !important; | |
| background-image: none !important; | |
| } | |
| [data-testid="stTextInput"] [data-baseweb="base-input"] { | |
| transition: none !important; | |
| background: transparent !important; | |
| background-color: transparent !important; | |
| } | |
| [data-testid="stTextInput"] button, | |
| [data-testid="stTextInput"] button * { | |
| color: #FFFFFF !important; | |
| fill: #FFFFFF !important; | |
| } | |
| [data-testid="stTextInput"] button { | |
| opacity: 0.85; | |
| transition: opacity 0.12s ease; | |
| } | |
| [data-testid="stTextInput"] button:hover { | |
| opacity: 1; | |
| } | |
| /* streamlit-js-eval renders invisible custom-component iframes for | |
| its localStorage round-trips. They reserve layout space (visible | |
| as a black line above 'Kia ora'). Collapse to zero height with | |
| overflow hidden — the iframes still mount and their JS still | |
| executes; they just don't take up vertical space. (Earlier I | |
| suspected this was breaking persistence, but the real culprit | |
| was the JS apostrophe-escaping bug in set_local_storage which | |
| is now patched.) */ | |
| [data-testid="stCustomComponentV1"], | |
| iframe[title^="streamlit_js_eval"] { | |
| height: 0 !important; | |
| min-height: 0 !important; | |
| overflow: hidden !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| border: 0 !important; | |
| } | |
| /* ── Typography ───────────────────────────────────────────────────── */ | |
| html, body, [class*="css"] { | |
| font-family: 'Instrument Sans', system-ui, -apple-system, sans-serif; | |
| } | |
| .stMarkdown p, .stChatMessage p { line-height: 1.65; font-weight: 400; } | |
| h1, h2, h3, h4, h5 { | |
| font-family: 'Instrument Sans', system-ui, sans-serif; | |
| font-weight: 700; | |
| letter-spacing: -0.022em; | |
| line-height: 1.25; | |
| } | |
| /* Horizontal rules inside chat bubbles — markdown '---' lines. | |
| Used by the LLM to divide the answer body from the Sources | |
| section. Subtle gray line with breathing room either side. */ | |
| [data-testid="stChatMessage"] hr { | |
| border: none !important; | |
| border-top: 1px solid #E5E7EB !important; | |
| margin: 1.6rem 0 0.4rem !important; | |
| } | |
| /* In-bubble heading scale — tighter than Streamlit defaults so | |
| long-form compliance answers read as articles, not posters. | |
| Hierarchy comes from BOTH size and colour/weight: | |
| h1/h2: violet bold (section markers — Short answer, Sources, etc.) | |
| h3: deep ink semibold (subsection — neutral so it doesn't | |
| compete with the violet h2 above it) | |
| h4–h6: muted dark medium (clearly subordinate) */ | |
| [data-testid="stChatMessage"] h1, | |
| [data-testid="stChatMessage"] h2 { | |
| color: #4023A8 !important; | |
| font-weight: 700 !important; | |
| } | |
| [data-testid="stChatMessage"] h3 { | |
| color: #1C2942 !important; | |
| font-weight: 600 !important; | |
| } | |
| [data-testid="stChatMessage"] h4, | |
| [data-testid="stChatMessage"] h5, | |
| [data-testid="stChatMessage"] h6 { | |
| color: #374151 !important; | |
| font-weight: 500 !important; | |
| } | |
| [data-testid="stChatMessage"] h1 { | |
| font-size: 1.625rem !important; | |
| line-height: 1.2 !important; | |
| margin: 1.4rem 0 0.55rem !important; | |
| } | |
| [data-testid="stChatMessage"] h2 { | |
| font-size: 1.375rem !important; | |
| line-height: 1.25 !important; | |
| margin: 1.3rem 0 0.5rem !important; | |
| } | |
| [data-testid="stChatMessage"] h3 { | |
| font-size: 1.15rem !important; | |
| line-height: 1.3 !important; | |
| margin: 1.1rem 0 0.45rem !important; | |
| } | |
| [data-testid="stChatMessage"] h4 { | |
| font-size: 1.05rem !important; | |
| line-height: 1.35 !important; | |
| margin: 0.95rem 0 0.4rem !important; | |
| } | |
| [data-testid="stChatMessage"] h5, | |
| [data-testid="stChatMessage"] h6 { | |
| font-size: 1rem !important; | |
| line-height: 1.4 !important; | |
| margin: 0.85rem 0 0.35rem !important; | |
| } | |
| /* Adjacent-sibling rule: when h3 immediately follows h2 (no body | |
| text between), tighten the gap so they sit as a paired heading | |
| rather than two separated section markers. Same for h3 → h4. */ | |
| [data-testid="stChatMessage"] h2 + h3, | |
| [data-testid="stChatMessage"] h2 + h4, | |
| [data-testid="stChatMessage"] h3 + h4 { | |
| margin-top: 0.4rem !important; | |
| } | |
| /* Roman (400) weight on bulleted lists immediately following an h2 — | |
| primarily the "Other things to consider" questions and the | |
| Sources link list. Forces any inline strong/em inside those list | |
| items to also render normal-weight. */ | |
| [data-testid="stChatMessage"] h2 + ul li, | |
| [data-testid="stChatMessage"] h2 + ul li *, | |
| [data-testid="stChatMessage"] h2 + ol li, | |
| [data-testid="stChatMessage"] h2 + ol li * { | |
| font-weight: 400 !important; | |
| } | |
| /* First heading in a bubble — no top margin so it sits flush */ | |
| [data-testid="stChatMessage"] h1:first-child, | |
| [data-testid="stChatMessage"] h2:first-child, | |
| [data-testid="stChatMessage"] h3:first-child, | |
| [data-testid="stChatMessage"] h4:first-child { | |
| margin-top: 0 !important; | |
| } | |
| /* Default text on the dark canvas (outside chat bubbles) — light. | |
| Streamlit wraps widget labels in stWidgetLabel, not <label>. */ | |
| [data-testid="stMain"] .stMarkdown, | |
| [data-testid="stMain"] [data-testid="stCaption"], | |
| [data-testid="stMain"] [data-testid="stWidgetLabel"], | |
| [data-testid="stMain"] [data-testid="stWidgetLabel"] *, | |
| [data-testid="stMain"] label { | |
| color: #EBEDF7 !important; | |
| } | |
| /* Inside chat bubbles, force dark text on white surface */ | |
| [data-testid="stChatMessage"] .stMarkdown, | |
| [data-testid="stChatMessage"] { | |
| color: #0B0B0C; | |
| } | |
| /* Hero title — gradient text effect. | |
| Lighter stops (#FFFFFF → #B89BFF) so the gradient reads against | |
| the saturated indigo canvas top — the previous violet→deep-violet | |
| was hue-similar to the bg and washed out. */ | |
| .hero-title { | |
| font-size: 2.6rem; | |
| font-weight: 700; | |
| letter-spacing: -0.025em; | |
| line-height: 1.1; | |
| margin: 0.2rem 0 0.6rem; | |
| background: linear-gradient(135deg, #FFFFFF 0%, #B89BFF 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| /* Pills heading — half-size sibling of hero-title, same lighter gradient */ | |
| .pills-label { | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| letter-spacing: -0.022em; | |
| line-height: 1.2; | |
| margin: 0.5rem 0 0.6rem; | |
| background: linear-gradient(135deg, #FFFFFF 0%, #B89BFF 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .hero-tagline { | |
| color: #EBEDF7; | |
| font-size: 1.05rem; | |
| font-weight: 400; | |
| line-height: 1.55; | |
| margin: 0 0 0.5rem; | |
| } | |
| .hero-disclaimer { | |
| color: #A89DC9; | |
| font-size: 0.85rem; | |
| font-style: italic; | |
| } | |
| /* ── Sidebar — dark with subtle violet glow at top ──────────────── */ | |
| [data-testid="stSidebar"] { | |
| background: linear-gradient(180deg, rgba(84, 47, 232, 0.18) 0%, rgba(11, 11, 12, 1) 55%); | |
| border-right: 1px solid rgba(255, 255, 255, 0.05); | |
| } | |
| /* Streamlit's main menu (kebab ⋮ in top-right toolbar) — items | |
| were rendering dark-on-dark via theme primitives. Inverting to | |
| a light 'system menu' surface against the dark canvas: white | |
| popover, deep-ink text, violet-tinted hover. */ | |
| [data-testid="stMainMenuPopover"], | |
| [data-testid="stMainMenuList"] { | |
| background: #FFFFFF !important; | |
| border: 1px solid rgba(221, 215, 232, 0.5) !important; | |
| border-radius: 10px !important; | |
| box-shadow: 0 10px 32px rgba(0, 0, 0, 0.45) !important; | |
| } | |
| [data-testid="stMainMenuItem"], | |
| [data-testid="stMainMenuItem"] *, | |
| [data-testid="stMainMenuItemLabel"], | |
| [data-testid="stMainMenuItemLabel"] * { | |
| color: #1C2942 !important; | |
| background-color: transparent !important; | |
| fill: #1C2942 !important; | |
| } | |
| [data-testid="stMainMenuItem"]:hover, | |
| [data-testid="stMainMenuItem"]:hover * { | |
| background-color: rgba(84, 47, 232, 0.1) !important; | |
| color: #542FE8 !important; | |
| fill: #542FE8 !important; | |
| } | |
| [data-testid="stMainMenuDivider"] { | |
| background: rgba(0, 0, 0, 0.08) !important; | |
| border-color: rgba(0, 0, 0, 0.08) !important; | |
| } | |
| /* The kebab button itself (in the toolbar) — keep three white dots. | |
| CRITICAL: the Material SVG has a <path fill="none"> bounding rect | |
| as the first child plus a separate <path> for the actual dots. | |
| A wildcard '*' selector forces fill on the bounding rect too, | |
| turning it into a solid white square that covers the dots. Use | |
| :not([fill="none"]) to skip the bounding rect. */ | |
| [data-testid="stMainMenuButton"] { | |
| color: #EBEDF7 !important; | |
| } | |
| [data-testid="stMainMenuButton"] svg path:not([fill="none"]), | |
| [data-testid="stMainMenuButton"] svg circle, | |
| [data-testid="stMainMenuButton"] svg rect:not([fill="none"]) { | |
| fill: #EBEDF7 !important; | |
| } | |
| [data-testid="stMainMenuButton"]:hover { | |
| background: rgba(255, 255, 255, 0.08) !important; | |
| } | |
| /* Sidebar collapse + expand chevrons — both were black on dark. | |
| stSidebarCollapseButton: the « inside the open sidebar. | |
| stExpandSidebarButton: the » floating on the canvas when | |
| the sidebar is collapsed. */ | |
| [data-testid="stSidebarCollapseButton"], | |
| [data-testid="stSidebarCollapseButton"] *, | |
| [data-testid="stSidebarCollapseButton"] svg, | |
| [data-testid="stSidebarHeader"] button, | |
| [data-testid="stSidebarHeader"] button svg, | |
| [data-testid="stExpandSidebarButton"], | |
| [data-testid="stExpandSidebarButton"] *, | |
| [data-testid="stExpandSidebarButton"] svg { | |
| color: #EBEDF7 !important; | |
| fill: #EBEDF7 !important; | |
| } | |
| [data-testid="stSidebarCollapseButton"]:hover, | |
| [data-testid="stSidebarHeader"] button:hover, | |
| [data-testid="stExpandSidebarButton"]:hover { | |
| background: rgba(255, 255, 255, 0.08) !important; | |
| } | |
| [data-testid="stSidebar"] .stMarkdown, | |
| [data-testid="stSidebar"] label, | |
| [data-testid="stSidebar"] [data-testid="stCaption"], | |
| [data-testid="stSidebar"] p { | |
| color: #EBEDF7; | |
| } | |
| [data-testid="stSidebar"] [data-testid="stCaption"] { | |
| color: #A89DC9 !important; | |
| } | |
| /* Sidebar selectbox — dark surface with light text (Streamlit's default | |
| reads theme.backgroundColor + theme.textColor which are both dark) */ | |
| [data-testid="stSidebar"] [data-baseweb="select"] > div { | |
| background: rgba(255, 255, 255, 0.06) !important; | |
| border-color: rgba(255, 255, 255, 0.14) !important; | |
| color: #EBEDF7 !important; | |
| border-radius: 10px !important; | |
| } | |
| [data-testid="stSidebar"] [data-baseweb="select"] *, | |
| [data-testid="stSidebar"] [data-baseweb="select"] [role="combobox"] { | |
| color: #EBEDF7 !important; | |
| } | |
| [data-testid="stSidebar"] [data-baseweb="select"] svg { | |
| fill: #A89DC9 !important; | |
| color: #A89DC9 !important; | |
| } | |
| /* Selectbox dropdown popover — dark surface to match the closed | |
| selectbox tile, white text. Belt-and-braces ' * ' selector | |
| forces the colour cascade through BaseWeb's nested spans. */ | |
| [data-baseweb="popover"] [data-baseweb="menu"], | |
| [data-baseweb="popover"] ul { | |
| background: #15131F !important; | |
| border: 1px solid rgba(255, 255, 255, 0.14) !important; | |
| border-radius: 10px !important; | |
| box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55) !important; | |
| } | |
| [data-baseweb="popover"] [data-baseweb="menu"] li, | |
| [data-baseweb="popover"] [data-baseweb="menu"] li *, | |
| [data-baseweb="popover"] ul li, | |
| [data-baseweb="popover"] ul li * { | |
| color: #EBEDF7 !important; | |
| background-color: transparent !important; | |
| } | |
| /* Hover — background wash only, no text-styling change */ | |
| [data-baseweb="popover"] [data-baseweb="menu"] li:hover, | |
| [data-baseweb="popover"] ul li:hover { | |
| background-color: rgba(84, 47, 232, 0.28) !important; | |
| } | |
| /* Selected item — subtle violet wash, no font-weight bump */ | |
| [data-baseweb="popover"] [data-baseweb="menu"] li[aria-selected="true"], | |
| [data-baseweb="popover"] ul li[aria-selected="true"] { | |
| background-color: rgba(84, 47, 232, 0.18) !important; | |
| } | |
| /* Past conversations rows — tighter than primary CTAs. | |
| Restore button (column 1) and delete ✕ (column 2) per row. | |
| Title button allows multi-line wrap so full question is readable. */ | |
| [data-testid="stSidebar"] [data-testid="stHorizontalBlock"] .stButton > button { | |
| font-size: 0.82rem !important; | |
| font-weight: 400 !important; | |
| padding: 0.55rem 0.7rem !important; | |
| text-align: left !important; | |
| justify-content: flex-start !important; | |
| background: rgba(255, 255, 255, 0.04) !important; | |
| background-image: none !important; | |
| color: #EBEDF7 !important; | |
| border: 1px solid rgba(255, 255, 255, 0.08) !important; | |
| } | |
| /* Allow past-conversation titles to wrap so the full question is | |
| readable — no ellipsis truncation. Streamlit's button label is | |
| nested 5 deep (button > div > span > div > p), and the inner | |
| SPAN has max-width:none which would let it grow to the natural | |
| text width without min-width:0 + max-width:100% on every | |
| descendant. With those constraints in place + white-space:normal | |
| the text wraps within the button's width. */ | |
| [data-testid="stSidebar"] [data-testid="stHorizontalBlock"] .stButton > button { | |
| min-width: 0 !important; | |
| height: auto !important; | |
| align-items: flex-start !important; | |
| } | |
| [data-testid="stSidebar"] [data-testid="stHorizontalBlock"] .stButton > button * { | |
| min-width: 0 !important; | |
| max-width: 100% !important; | |
| white-space: normal !important; | |
| overflow: visible !important; | |
| text-overflow: clip !important; | |
| word-wrap: break-word !important; | |
| overflow-wrap: break-word !important; | |
| line-height: 1.35 !important; | |
| } | |
| [data-testid="stSidebar"] [data-testid="stHorizontalBlock"] .stButton > button:hover { | |
| background: rgba(84, 47, 232, 0.18) !important; | |
| background-image: none !important; | |
| border-color: rgba(84, 47, 232, 0.4) !important; | |
| box-shadow: none !important; | |
| } | |
| /* Sidebar section labels — muted brand violet */ | |
| .sidebar-section { | |
| color: #A89DC9; | |
| font-size: 0.72rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| font-weight: 600; | |
| margin: 1.25rem 0 0.5rem; | |
| } | |
| .sidebar-section:first-of-type { margin-top: 0; } | |
| /* ── Expander (Full citation detail) — light card matching the | |
| bubble. Header was rendering dark-on-dark from theme primitives. */ | |
| [data-testid="stExpander"] { | |
| border: 1px solid #DDD7E8 !important; | |
| border-radius: 10px !important; | |
| background: #FFFFFF !important; | |
| overflow: hidden !important; | |
| } | |
| [data-testid="stExpander"] summary, | |
| [data-testid="stExpander"] details > summary { | |
| background: #FFFFFF !important; | |
| color: #1C2942 !important; | |
| font-family: 'Instrument Sans', sans-serif !important; | |
| font-weight: 500 !important; | |
| } | |
| [data-testid="stExpander"] summary:hover { | |
| background: rgba(84, 47, 232, 0.05) !important; | |
| } | |
| [data-testid="stExpander"] [data-testid="stExpanderIcon"], | |
| [data-testid="stExpander"] summary svg { | |
| color: #4023A8 !important; | |
| fill: #4023A8 !important; | |
| } | |
| [data-testid="stExpander"] [data-testid="stExpanderDetails"] { | |
| background: #FFFFFF !important; | |
| color: #1C2942 !important; | |
| } | |
| /* Cascade dark text to all descendants — including markdown <p>/<li> */ | |
| [data-testid="stExpander"] [data-testid="stExpanderDetails"] *, | |
| [data-testid="stExpander"] [data-testid="stExpanderDetails"] p, | |
| [data-testid="stExpander"] [data-testid="stExpanderDetails"] li { | |
| color: #1C2942 !important; | |
| } | |
| /* ── Citation chips — match pills (solid violet, white text), with | |
| inverted hover so darker is the active state ─────────────────── */ | |
| .citation-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.4rem; | |
| margin: 0.7rem 0 0.4rem; | |
| } | |
| .citation-chip, | |
| a.citation-chip { | |
| display: inline-block; | |
| background: #5239BC !important; | |
| color: #FFFFFF !important; | |
| padding: 0.32rem 0.85rem; | |
| border-radius: 9999px !important; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| text-decoration: none !important; | |
| border: none !important; | |
| border-bottom: none !important; | |
| opacity: 0.6; | |
| transition: all 0.16s ease; | |
| } | |
| a.citation-chip:hover { | |
| background: #4023A8 !important; | |
| color: #FFFFFF !important; | |
| text-decoration: none !important; | |
| opacity: 1; | |
| box-shadow: 0 2px 10px rgba(84, 47, 232, 0.32) !important; | |
| } | |
| /* ── Chat bubbles — white surfaces float on dark canvas ──────────── */ | |
| /* Streamlit splits chat_message into two testids: | |
| stChatMessage = outer flex wrapper (avatar + content) | |
| stChatMessageContent = inner content area (markdown, expander, columns) | |
| Apply the bubble bg + padding to BOTH so all children — including | |
| st.columns — stay on the white surface. Avatar overrides keep | |
| their own gradient background. */ | |
| [data-testid="stChatMessage"], | |
| [data-testid="stChatMessageContent"] { | |
| background: #FFFFFF !important; | |
| border-radius: 14px; | |
| margin-bottom: 1.4rem; | |
| } | |
| [data-testid="stChatMessage"] { | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| /* Assistant messages get generous padding — long-form responses | |
| need breathing room. Right-padding is wide so long lines have | |
| a comfortable measure inside the bubble. */ | |
| padding: 2rem 6rem 4rem 4rem; | |
| box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); | |
| } | |
| /* Inner content area — no extra padding/margin, no rogue bg */ | |
| [data-testid="stChatMessageContent"] { | |
| padding: 0 !important; | |
| box-shadow: none !important; | |
| border: none !important; | |
| } | |
| /* User message — narrow, right-aligned, violet panel matching the | |
| chat input. White h2 text inside (set in the rule below). */ | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]), | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) [data-testid="stChatMessageContent"] { | |
| background: #4023A8 !important; | |
| } | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) { | |
| border: 1px solid rgba(255, 255, 255, 0.2) !important; | |
| padding: 1.1rem 1.4rem 1.2rem !important; | |
| max-width: 75% !important; | |
| margin-left: auto !important; | |
| margin-right: 0 !important; | |
| } | |
| /* Hide the user avatar — drops the redundant 'who is this' marker | |
| since right-align already communicates that */ | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) [data-testid="stChatMessageAvatarUser"] { | |
| display: none !important; | |
| } | |
| /* Style the user's question text at h3 weight — semibold (600), | |
| 1.15rem, white on the violet bubble bg. Smaller than h2 so it | |
| doesn't compete with the assistant's '## Short answer:' h2 in | |
| the response below. */ | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) [data-testid="stMarkdown"] p, | |
| [data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) p { | |
| font-size: 1.15rem !important; | |
| font-weight: 600 !important; | |
| color: #FFFFFF !important; | |
| line-height: 1.3 !important; | |
| letter-spacing: -0.018em !important; | |
| margin: 0 !important; | |
| } | |
| /* All direct children of the bubble's content area share the same | |
| left edge — no rogue margins on citation row, expander, or | |
| horizontal block (timing/save row) */ | |
| [data-testid="stChatMessageContent"] .citation-row, | |
| [data-testid="stChatMessageContent"] [data-testid="stExpander"], | |
| [data-testid="stChatMessageContent"] [data-testid="stHorizontalBlock"] { | |
| margin-left: 0 !important; | |
| margin-right: 0 !important; | |
| } | |
| /* Timing/save row — sits at the bottom of the bubble, gets a touch | |
| of breathing room above so it doesn't crowd the expander */ | |
| [data-testid="stChatMessageContent"] [data-testid="stHorizontalBlock"]:last-child { | |
| margin-top: 0.65rem !important; | |
| padding-top: 0.5rem !important; | |
| border-top: 1px solid #EEF1F4; | |
| } | |
| /* Caption inside the timing row — explicit dark text on white */ | |
| [data-testid="stChatMessageContent"] [data-testid="stHorizontalBlock"] [data-testid="stCaption"], | |
| [data-testid="stChatMessageContent"] [data-testid="stHorizontalBlock"] small { | |
| color: #6B7280 !important; | |
| } | |
| /* ── Avatar circles — brand-coloured, white icons ────────────────── */ | |
| [data-testid="stChatMessageAvatarUser"] { | |
| background: linear-gradient(135deg, #59499B 0%, #33177A 100%) !important; | |
| margin-top: 0.4rem !important; | |
| } | |
| [data-testid="stChatMessageAvatarAssistant"] { | |
| background: linear-gradient(135deg, #542FE8 0%, #1E3798 100%) !important; | |
| margin-top: 0.4rem !important; | |
| } | |
| /* Avatars use Material Symbols (font icons) not SVG — broaden the | |
| selector to catch every descendant: font glyphs read 'color', | |
| SVGs read 'fill', and we set both for safety. */ | |
| [data-testid="stChatMessageAvatarUser"] *, | |
| [data-testid="stChatMessageAvatarAssistant"] *, | |
| [data-testid="stChatMessageAvatarUser"] [data-testid="stIconMaterial"], | |
| [data-testid="stChatMessageAvatarAssistant"] [data-testid="stIconMaterial"] { | |
| color: #FFFFFF !important; | |
| fill: #FFFFFF !important; | |
| } | |
| /* ── Footer ──────────────────────────────────────────────────────── */ | |
| .footer-quiet { | |
| color: #A89DC9; | |
| font-size: 0.78rem; | |
| text-align: center; | |
| margin-top: 2.5rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| /* ── Layout — explicit symmetric horizontal padding ──────────────── */ | |
| .block-container { | |
| padding-top: 2.4rem !important; | |
| padding-left: 1.5rem !important; | |
| padding-right: 1.5rem !important; | |
| max-width: 920px !important; | |
| } | |
| /* Match the bottom (chat input) container's horizontal padding so | |
| the input panel aligns column-flush with the chat history above */ | |
| [data-testid="stBottomBlockContainer"] { | |
| padding-left: 1.5rem !important; | |
| padding-right: 1.5rem !important; | |
| } | |
| /* Markdown inside chat messages — ensure no extra right-only margin | |
| leaks in from Streamlit defaults */ | |
| [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] { | |
| max-width: 100% !important; | |
| padding-right: 0 !important; | |
| margin-right: 0 !important; | |
| } | |
| /* ── Pills — match the chat input panel (medium violet #4023A8) ── */ | |
| [data-testid="stButtonGroup"] button { | |
| background: #4023A8 !important; | |
| color: #FFFFFF !important; | |
| border-radius: 9999px !important; | |
| border-color: transparent !important; | |
| font-family: 'Instrument Sans', sans-serif !important; | |
| font-weight: 500 !important; | |
| transition: all 0.16s ease !important; | |
| } | |
| [data-testid="stButtonGroup"] button:hover { | |
| background: #5239BC !important; | |
| color: #FFFFFF !important; | |
| border-color: transparent !important; | |
| box-shadow: 0 2px 10px rgba(84, 47, 232, 0.25) !important; | |
| } | |
| [data-testid="stButtonGroup"] button[aria-checked="true"], | |
| [data-testid="stButtonGroup"] button[aria-pressed="true"] { | |
| background: linear-gradient(135deg, #542FE8 0%, #1E3798 100%) !important; | |
| color: #FFFFFF !important; | |
| border-color: transparent !important; | |
| box-shadow: 0 2px 12px rgba(84, 47, 232, 0.4) !important; | |
| } | |
| /* ── Buttons — pill shape, violet gradient, white text everywhere ── */ | |
| .stButton > button, | |
| .stDownloadButton > button { | |
| background: linear-gradient(135deg, #542FE8 0%, #1E3798 100%) !important; | |
| color: #FFFFFF !important; | |
| border: none !important; | |
| border-radius: 9999px !important; | |
| font-weight: 500 !important; | |
| font-family: 'Instrument Sans', sans-serif !important; | |
| transition: all 0.16s ease !important; | |
| } | |
| .stButton > button:hover, | |
| .stDownloadButton > button:hover { | |
| box-shadow: 0 2px 12px rgba(84, 47, 232, 0.35) !important; | |
| color: #FFFFFF !important; | |
| } | |
| /* Force the inner span/text to inherit white — Streamlit wraps the | |
| label in a <p> or <span> that can pick up theme.textColor */ | |
| .stButton > button *, | |
| .stDownloadButton > button * { | |
| color: #FFFFFF !important; | |
| } | |
| /* ── Links — violet, no underline by default ─────────────────────── */ | |
| a:not(.citation-chip) { | |
| color: #542FE8; | |
| text-decoration: none; | |
| border-bottom: 1px solid rgba(84, 47, 232, 0.25); | |
| transition: border-color 0.16s ease; | |
| } | |
| a:not(.citation-chip):hover { | |
| border-bottom-color: #542FE8; | |
| } | |
| /* ── Chat input — solid medium-violet panel, transparent textarea ── */ | |
| /* The bottom container that pins the input to the viewport. | |
| Match the 920px max-width of .block-container above so the input | |
| aligns visually with the content column. */ | |
| [data-testid="stBottom"] { | |
| background: transparent !important; | |
| } | |
| /* The actual chat-input frame element — emotion-styled class. | |
| NOTE: emotion class names are version-specific and can change on | |
| Streamlit upgrade. If this stops working, inspect the bottom | |
| container in DevTools and update the class name. */ | |
| .st-emotion-cache-1k4veml { | |
| background: #0F0027 !important; | |
| } | |
| [data-testid="stBottomBlockContainer"] { | |
| background: transparent !important; | |
| max-width: 920px !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| } | |
| /* Disclaimer beneath the chat input — injected via ::after so it | |
| stays pinned to the bottom container regardless of scroll, and | |
| doesn't need a Python widget that might escape the layout. */ | |
| [data-testid="stBottomBlockContainer"]::after { | |
| content: "Prototype — not legal advice. Always verify against source legislation and codes."; | |
| display: block; | |
| text-align: center; | |
| color: #A89DC9; | |
| font-size: 0.78rem; | |
| font-style: italic; | |
| margin: 0.5rem 0 0.4rem; | |
| padding: 0 1rem; | |
| } | |
| /* The outer panel — solid medium violet, no border */ | |
| [data-testid="stChatInput"] { | |
| background: #4023A8 !important; | |
| border: 1px solid transparent !important; | |
| border-radius: 14px !important; | |
| box-shadow: 0 6px 24px rgba(84, 47, 232, 0.28) !important; | |
| } | |
| [data-testid="stChatInput"]:focus-within { | |
| border-color: #542FE8 !important; | |
| box-shadow: 0 0 0 2px rgba(84, 47, 232, 0.4), | |
| 0 6px 24px rgba(84, 47, 232, 0.4) !important; | |
| } | |
| /* All inner BaseWeb wrappers transparent so panel shows through */ | |
| [data-testid="stChatInput"] > div, | |
| [data-testid="stChatInput"] [data-baseweb="textarea"], | |
| [data-testid="stChatInput"] [data-baseweb="base-input"] { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| [data-testid="stChatInput"] textarea { | |
| color: #FFFFFF !important; | |
| background: transparent !important; | |
| font-family: 'Instrument Sans', sans-serif !important; | |
| caret-color: #FFFFFF !important; | |
| } | |
| [data-testid="stChatInput"] textarea::placeholder { | |
| color: rgba(255, 255, 255, 0.65) !important; | |
| opacity: 1 !important; | |
| } | |
| /* Send button — white icon, light-alpha hover wash */ | |
| [data-testid="stChatInput"] button { | |
| color: rgba(255, 255, 255, 0.8) !important; | |
| background: transparent !important; | |
| } | |
| [data-testid="stChatInput"] button:hover { | |
| background: rgba(255, 255, 255, 0.15) !important; | |
| color: #FFFFFF !important; | |
| } | |
| [data-testid="stChatInput"] button svg { | |
| color: inherit !important; | |
| fill: currentColor !important; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Password gate ──────────────────────────────────────────────────────────── | |
| # Public Space + APP_PASSWORD set as a secret means anyone can hit the URL but | |
| # only people with the password can use the app. If APP_PASSWORD isn't set | |
| # (e.g. local dev), the gate is bypassed. | |
| import hmac | |
| def _check_password() -> bool: | |
| expected = os.environ.get("APP_PASSWORD") | |
| if not expected: | |
| return True # No password configured — open access (local dev) | |
| if st.session_state.get("password_correct"): | |
| return True | |
| def _verify(): | |
| if hmac.compare_digest(st.session_state.get("password", ""), expected): | |
| st.session_state["password_correct"] = True | |
| st.session_state.pop("password", None) | |
| else: | |
| st.session_state["password_correct"] = False | |
| st.markdown("### Complementary Medicine Marketing Compliance") | |
| st.caption("Prototype — password required.") | |
| st.text_input("Password", type="password", on_change=_verify, key="password") | |
| # Explicit Log in button — the on_change handler covers Enter-to-submit, | |
| # this button covers click-to-submit (and signals to users that there's | |
| # an action, rather than asking them to guess). | |
| if st.button("Log in", type="primary", key="login_submit"): | |
| _verify() | |
| if st.session_state.get("password_correct") is False: | |
| st.error("Incorrect password.") | |
| return False | |
| if not _check_password(): | |
| st.stop() | |
| # ── Session state ──────────────────────────────────────────────────────────── | |
| if "messages" not in st.session_state: | |
| st.session_state.messages = [] | |
| if "history" not in st.session_state: | |
| # LLM-formatted history for the pipeline: [{"role": "user"|"assistant", "content": "..."}] | |
| st.session_state.history = [] | |
| if "results" not in st.session_state: | |
| # Store full result objects keyed by message index for expandable details | |
| st.session_state.results = {} | |
| if "conversation_id" not in st.session_state: | |
| # The active conversation's UUID. None until the user sends their first | |
| # message in a session, at which point we allocate one and start | |
| # persisting after each response. Restored conversations adopt their | |
| # saved id so subsequent messages append to the same record. | |
| st.session_state.conversation_id = None | |
| # Read the cached past-conversations list from localStorage once per session. | |
| # First render returns None and triggers a Streamlit rerun once the JS | |
| # component completes. Cached in session_state thereafter. | |
| persistence.bootstrap_load() | |
| persistence.bootstrap_load_saved() | |
| # Process any deletes queued by the click handler in a previous render. | |
| # Doing the actual delete+write here (rather than inside the click handler) | |
| # avoids the st.rerun() race that was killing the localStorage write. | |
| persistence.process_pending() | |
| persistence.process_pending_saved() | |
| # ── Sidebar ────────────────────────────────────────────────────────────────── | |
| PROFESSIONS = [ | |
| "Select Profession", | |
| "Chiropractor", | |
| "Osteopath", | |
| "Physiotherapist", | |
| "Chinese Medicine", | |
| "Naturopath", | |
| "Supplement Retailer", | |
| "Other", | |
| ] | |
| # Reset to PROFESSIONS[0] if the saved value isn't in the current list — handles | |
| # the rename from 'General Advice' for users with existing session state. | |
| if "profession" not in st.session_state or st.session_state.profession not in PROFESSIONS: | |
| st.session_state.profession = PROFESSIONS[0] | |
| with st.sidebar: | |
| # ── What do you do ────────────────────────────────────────────────────── | |
| st.markdown('<div class="sidebar-section">What do you do</div>', unsafe_allow_html=True) | |
| st.session_state.profession = st.selectbox( | |
| "I am a…", | |
| PROFESSIONS, | |
| index=PROFESSIONS.index(st.session_state.profession), | |
| help="Setting your profession scopes answers to the council/board rules that bind you specifically. The Medical Council's rules are the strictest in the country and only bind doctors — leaving this set to 'general' may surface their guidance as comparative, not authoritative.", | |
| label_visibility="collapsed", | |
| ) | |
| if st.session_state.profession != PROFESSIONS[0]: | |
| st.caption(f"Answers will treat **{st.session_state.profession}** rules as authoritative.") | |
| # ── Conversation ──────────────────────────────────────────────────────── | |
| st.markdown('<div class="sidebar-section">Conversation</div>', unsafe_allow_html=True) | |
| if st.button("Start a new conversation", use_container_width=True): | |
| st.session_state.messages = [] | |
| st.session_state.history = [] | |
| st.session_state.results = {} | |
| st.session_state.conversation_id = None | |
| st.session_state.pop("starter_pill", None) | |
| st.session_state.pop("_starter_handled", None) | |
| st.rerun() | |
| # Aggregated download of every saved response from this browser. The | |
| # per-message Save button (in chat) bookmarks Q&A pairs in-app; this | |
| # download produces the .md file when the user wants the artefact. | |
| saved_responses = persistence.load_all_saved() | |
| if saved_responses: | |
| st.download_button( | |
| "Download saved responses", | |
| data=format_saved_responses_aggregate(saved_responses), | |
| file_name=f"health-marketing-saved-responses-{datetime.now().strftime('%Y%m%d')}.md", | |
| mime="text/markdown", | |
| use_container_width=True, | |
| key="download_saved_responses", | |
| ) | |
| # ── Saved Responses ───────────────────────────────────────────────────── | |
| # Per-Q&A bookmarks. Click the question to fork: the saved snapshot | |
| # (full context up to that pair) restores into a NEW conversation_id, | |
| # leaving the original conversation untouched. | |
| if saved_responses: | |
| st.markdown( | |
| '<div class="sidebar-section">Saved Responses</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| for saved in saved_responses: | |
| saved_id = saved["id"] | |
| question = saved.get("question", "(no question)") | |
| col_title, col_delete = st.columns([5, 1], gap="small") | |
| if col_title.button( | |
| question, | |
| key=f"restore_saved_{saved_id}", | |
| use_container_width=True, | |
| ): | |
| persistence.restore_from_saved(saved) | |
| st.rerun() | |
| if col_delete.button( | |
| "✕", | |
| key=f"delete_saved_{saved_id}", | |
| ): | |
| # Same deferred-delete pattern as conversations — sidestep | |
| # the st.rerun() race that kills the localStorage write. | |
| persistence.queue_delete_saved(saved_id) | |
| st.rerun() | |
| # ── Recent conversations ──────────────────────────────────────────────── | |
| # Auto-saved threads. Each row: title button (5/6 width) + | |
| # delete button (1/6 width). Click title to resume the conversation | |
| # (same conversation_id, appends new questions); click ✕ to remove. | |
| past_conversations = persistence.load_all() | |
| if past_conversations: | |
| st.markdown( | |
| '<div class="sidebar-section">Recent conversations</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| for conv in past_conversations: | |
| conv_id = conv["id"] | |
| title = conv.get("title", "Untitled") | |
| is_active = conv_id == st.session_state.conversation_id | |
| # Marker prefix for the active conversation | |
| label = ("▸ " + title) if is_active else title | |
| col_title, col_delete = st.columns([5, 1], gap="small") | |
| if col_title.button( | |
| label, | |
| key=f"restore_{conv_id}", | |
| use_container_width=True, | |
| ): | |
| loaded = persistence.load_conversation(conv_id) | |
| if loaded: | |
| persistence.restore_state(loaded) | |
| st.rerun() | |
| if col_delete.button( | |
| "✕", | |
| key=f"delete_{conv_id}", | |
| ): | |
| # Queue the delete instead of running it inline — the actual | |
| # localStorage write happens at the top of the next render | |
| # via persistence.process_pending(). Sidesteps the st.rerun() | |
| # race that was killing the JS component. | |
| persistence.queue_delete(conv_id) | |
| # If we deleted the currently-active conversation, also clear | |
| # the in-memory state so the UI doesn't show a phantom thread | |
| if is_active: | |
| st.session_state.messages = [] | |
| st.session_state.history = [] | |
| st.session_state.results = {} | |
| st.session_state.conversation_id = None | |
| st.rerun() | |
| st.caption("_Stored on this browser only — not synced across devices._") | |
| # Quiet disclaimer at the bottom of the sidebar — only thing kept from | |
| # the old "Behind the scenes" section. Corpus sizes and model name | |
| # were dev-flavour, not useful to end users. | |
| st.markdown('<div style="margin-top:1.75rem"></div>', unsafe_allow_html=True) | |
| st.caption("_Prototype — not legal advice. Always verify against source legislation._") | |
| # ── Chat display ───────────────────────────────────────────────────────────── | |
| # Welcome message if no history | |
| STARTER_QUESTIONS = [ | |
| "Can I include patient testimonials on my website?", | |
| "Can I claim my supplement reduces inflammation?", | |
| "Can I email my patient list a newsletter?", | |
| "Can I advertise that I'm an ACC provider?", | |
| ] | |
| if not st.session_state.messages: | |
| # Hero — gradient title (violet→deep violet) with plain emoji wave | |
| st.markdown( | |
| '<div><span class="hero-title">Kia ora</span> ' | |
| '<span style="font-size:2.4rem">👋</span></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<p class="hero-tagline">Compliance answers for NZ practitioners ' | |
| 'and supplement sellers — with citations to the rule that applies.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<p class="hero-disclaimer">Prototype, not legal advice. ' | |
| 'Verify against source rules and seek specialist advice for ' | |
| 'borderline situations.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.write("") | |
| # Custom gradient heading (Streamlit's stWidgetLabel inherits theme | |
| # textColor and is hard to retarget reliably). Pill's own label is | |
| # collapsed so we don't render it twice. | |
| st.markdown( | |
| '<div class="pills-label">Try one of these</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # st.pills (native, Streamlit 2026) replaces the old 3-column buttons. | |
| # We track the last-handled value so that the persisted pill state | |
| # doesn't re-fire pending_query on every rerun. | |
| pick = st.pills( | |
| "Try one of these", | |
| STARTER_QUESTIONS, | |
| selection_mode="single", | |
| key="starter_pill", | |
| label_visibility="collapsed", | |
| ) | |
| if pick and pick != st.session_state.get("_starter_handled"): | |
| st.session_state._starter_handled = pick | |
| st.session_state.pending_query = pick | |
| st.rerun() | |
| # Render chat history | |
| for i, msg in enumerate(st.session_state.messages): | |
| with st.chat_message(msg["role"]): | |
| st.markdown(msg["content"]) | |
| # Show expandable details for assistant messages | |
| if msg["role"] == "assistant" and i in st.session_state.results: | |
| result = st.session_state.results[i] | |
| # Rewritten query indicator | |
| if result.get("standalone_query"): | |
| st.caption(f"Interpreted as: _{result['standalone_query']}_") | |
| # Citations — chip row + collapsible full detail | |
| _render_citations(result.get("citations") or []) | |
| # Timing and save button | |
| col_timing, col_save = st.columns([3, 1]) | |
| if result.get("timing"): | |
| col_timing.caption(_timing_caption(result)) | |
| # Per-message Save — bookmark this Q&A pair into Saved | |
| # Responses (sidebar list). Idempotent on (conversation_id, | |
| # msg_index): re-saving the same pair is a no-op. Click | |
| # produces NO file download — see sidebar 'Download saved | |
| # responses' for the aggregated .md export. | |
| prev_query = "" | |
| for j in range(i - 1, -1, -1): | |
| if st.session_state.messages[j]["role"] == "user": | |
| prev_query = st.session_state.messages[j]["content"] | |
| break | |
| already_saved = persistence.is_response_saved( | |
| st.session_state.conversation_id, i | |
| ) | |
| save_label = "Saved ✓" if already_saved else "Save Response" | |
| if col_save.button( | |
| save_label, | |
| key=f"save_history_{i}", | |
| disabled=already_saved, | |
| use_container_width=True, | |
| ): | |
| persistence.save_response( | |
| conversation_id=st.session_state.conversation_id, | |
| msg_index=i, | |
| question=prev_query, | |
| answer=msg["content"], | |
| result=result, | |
| source_conversation_title=persistence.title_from_messages( | |
| st.session_state.messages | |
| ), | |
| ) | |
| st.rerun() | |
| # ── Chat input ─────────────────────────────────────────────────────────────── | |
| chat_query = st.chat_input("Ask a compliance question...") | |
| pending_query = st.session_state.pop("pending_query", None) | |
| query = chat_query or pending_query | |
| if query: | |
| # Allocate a conversation_id on the first message of a fresh session, | |
| # so subsequent saves all upsert against the same record in localStorage. | |
| if st.session_state.conversation_id is None: | |
| st.session_state.conversation_id = persistence.new_conversation_id() | |
| # Add user message to display | |
| st.session_state.messages.append({"role": "user", "content": query}) | |
| with st.chat_message("user"): | |
| st.markdown(query) | |
| # Generate response with streaming | |
| with st.chat_message("assistant"): | |
| # Resolve profession for this query (if user has one set) | |
| active_profession = ( | |
| st.session_state.profession | |
| if st.session_state.profession != PROFESSIONS[0] | |
| else None | |
| ) | |
| # Phase 1: retrieval with spinner | |
| with st.spinner("Searching compliance documents..."): | |
| retrieval = run_query_retrieval( | |
| query, | |
| history=st.session_state.history, | |
| profession=active_profession, | |
| ) | |
| # Phase 2: stream the answer | |
| stream = run_query_stream( | |
| query, | |
| retrieval, | |
| history=st.session_state.history, | |
| profession=active_profession, | |
| ) | |
| # Collect text chunks for st.write_stream, capture metadata at the end. | |
| # Use a list as a mutable container since this runs at module scope | |
| # (Streamlit scripts have no enclosing function for `nonlocal`). | |
| result_holder = [] | |
| def _answer_stream(): | |
| for chunk in stream: | |
| if isinstance(chunk, str): | |
| yield chunk | |
| else: | |
| result_holder.append(chunk) | |
| st.write_stream(_answer_stream) | |
| result = result_holder[0] if result_holder else None | |
| # Rewritten query indicator | |
| standalone = retrieval.get("standalone_query") | |
| if standalone: | |
| st.caption(f"Interpreted as: _{standalone}_") | |
| # Citations — chip row + collapsible full detail | |
| if result: | |
| _render_citations(result.get("citations") or []) | |
| # Commit the assistant message + result to session_state HERE, | |
| # before the Save button renders. The button needs the full | |
| # snapshot — including this assistant message at its final index — | |
| # so save_response() captures the correct context. Doing this | |
| # inside the chat_message block (rather than after) keeps the | |
| # Save click handler reading from up-to-date state. | |
| answer_text = result["answer"] if result else "" | |
| msg_index = len(st.session_state.messages) | |
| st.session_state.messages.append({"role": "assistant", "content": answer_text}) | |
| st.session_state.results[msg_index] = result or {} | |
| st.session_state.history.append({"role": "user", "content": query}) | |
| st.session_state.history.append({"role": "assistant", "content": answer_text[:500]}) | |
| # Timing and save button | |
| col_timing, col_save = st.columns([3, 1]) | |
| if result and result.get("timing"): | |
| col_timing.caption(_timing_caption(result)) | |
| if result: | |
| # Per-message Save — bookmarks this Q&A to Saved Responses. | |
| # No file produced; see sidebar 'Download saved responses' for | |
| # the aggregated .md export. | |
| already_saved = persistence.is_response_saved( | |
| st.session_state.conversation_id, msg_index | |
| ) | |
| save_label = "Saved ✓" if already_saved else "Save Response" | |
| if col_save.button( | |
| save_label, | |
| key=f"save_live_{msg_index}", | |
| disabled=already_saved, | |
| use_container_width=True, | |
| ): | |
| persistence.save_response( | |
| conversation_id=st.session_state.conversation_id, | |
| msg_index=msg_index, | |
| question=query, | |
| answer=answer_text, | |
| result=result, | |
| source_conversation_title=persistence.title_from_messages( | |
| st.session_state.messages | |
| ), | |
| ) | |
| st.rerun() | |
| # Persist the conversation to browser localStorage. Runs after the | |
| # response has fully streamed and state is updated. No st.rerun() in | |
| # this path so no race with the localStorage write. | |
| persistence.save_conversation( | |
| st.session_state.conversation_id, | |
| st.session_state.messages, | |
| st.session_state.history, | |
| st.session_state.results, | |
| ) | |