""" 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 = ['
'] 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'{safe_title}' ) else: chips_html_parts.append(f'{safe_title}') chips_html_parts.append("
") 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( """ """, 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('', 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('', 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( '', 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( '', 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('
', 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( '
Kia ora ' '👋
', unsafe_allow_html=True, ) st.markdown( '

Compliance answers for NZ practitioners ' 'and supplement sellers — with citations to the rule that applies.

', unsafe_allow_html=True, ) st.markdown( '

Prototype, not legal advice. ' 'Verify against source rules and seek specialist advice for ' 'borderline situations.

', 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( '
Try one of these
', 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, )