hmc-rag / app.py
webmuppet
Saved responses: store just the pair, not the full thread
baa1903
"""
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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,
)