"""
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,
)