Multimodal-RAG-Engine / streamlit_app.py
raguram3398's picture
Upload folder using huggingface_hub
fde469c verified
Raw History Blame Contribute Delete
7.66 kB
"""One Streamlit page over the RAG pipeline (hybrid + rerank + the
version/year rule; SELF_CHECK off). Each visitor brings their own Anthropic key;
the container holds only PINECONE_API_KEY.
streamlit run streamlit_app.py (in Docker: docker compose up)
"""
import re
from dataclasses import dataclass, replace
import anthropic
import pandas as pd
import streamlit as st
from multimodal_rag.answer import answered, ask
from multimodal_rag.config import EXAMPLE_QUESTIONS, SESSION_QUERY_CAP
from multimodal_rag.contracts import Answer, Hit
from multimodal_rag.evaluate import live_scores, ragas_embeddings
from multimodal_rag.retrieve import QueryError, Retriever
QUOTE_CHARS = 200 # citation quotes shown under an answer are cut to this
@dataclass(frozen=True)
class Turn:
"""One question and what came back. `answer` is None and `error` is set when
the query path raised QueryError. `live` = RAGAS (faithfulness, answer
relevancy, cost_usd), filled after the answer is shown; None until then and
for abstains, refusals and errors."""
question: str
answer: Answer | None
hits: list[Hit]
error: str | None = None
live: tuple[float, float, float] | None = None
@st.cache_resource(show_spinner="Loading BGE, the reranker and BM25 (once)…")
def get_retriever() -> Retriever:
"""One Retriever per server process, shared by every session: it is ~1.2 GB
of process memory (torch, BGE, the cross-encoder, BM25; measured), which
would not fit once per visitor. 8 searches from 4 threads matched the
sequential results."""
return Retriever()
@st.cache_resource(show_spinner=False)
def get_embeddings():
"""One BGE copy for live answer relevancy, shared by every session."""
return ragas_embeddings()
def run_turn(question: str, history: list[str], api_key: str) -> Turn:
"""Answer `question` with the visitor's key. `history` = the session's
earlier questions, so a follow-up is rewritten to stand alone."""
client = anthropic.Anthropic(api_key=api_key)
try:
ans, hits = ask(question, get_retriever(), client, history)
except QueryError as e:
return Turn(question, None, [], str(e))
return Turn(question, ans, hits)
def as_text(s: str) -> str:
"""`s` escaped so Streamlit markdown shows it as written (slide text holds
"*", "_", "$" and brackets)."""
return re.sub(r"([\\`*_\[\]()#~<>$|])", r"\\\1", s)
def show_answer(t: Turn) -> None:
"""Left column: the answer with its citations, or the abstain, refusal or
error."""
st.markdown(f"#### {as_text(t.question)}")
if t.error:
st.error(f"{t.error}\n\nNothing was answered. Try again in a moment.")
return
if not answered(t.answer):
st.info(t.answer.text)
return
# Claude writes markdown (bold, lists); only "$" is escaped: 26 chunks hold
# two or more dollar amounts, which would render as LaTeX between them.
st.markdown(t.answer.text.replace("$", "\\$"))
lines = [] # Claude often cites the same passage for several claims
for c in t.answer.citations:
mark = " (AI description of the slide)" if c.generated else ""
quote = " ".join(c.cited_text.split()) # slide quotes span many lines
if len(quote) > QUOTE_CHARS:
quote = quote[:QUOTE_CHARS].rsplit(" ", 1)[0] + " …"
line = f"{c.doc_id}, p.{c.page + 1}{mark}: “{quote}”"
if line not in lines:
lines.append(line)
st.markdown("**Citations**")
for line in lines:
st.caption(as_text(line))
def show_metrics(t: Turn) -> None:
"""Right column: this question's cost and latency, the pages Claude read,
and the live RAGAS scores (or a spinner slot while they are computed)."""
if t.error:
return
a = t.answer
st.markdown("##### This question")
cost, latency = st.columns(2)
cost.metric("Answer cost", f"${a.cost_usd:.4f}")
latency.metric("Latency", f"{a.latency_s:.1f} s")
st.markdown("##### Pages Claude Haiku read")
pages = pd.DataFrame(
{
"page": [h.chunk.page + 1 for h in t.hits],
"relevance": [round(h.score, 2) for h in t.hits],
"document": [h.chunk.doc_id for h in t.hits],
}
)
st.dataframe(pages, hide_index=True, height=200)
st.caption("Relevance: the reranker's score; higher is more relevant.")
st.markdown("##### Answer quality (RAGAS)")
if t.live:
faith, rel, scoring = t.live
f_col, r_col = st.columns(2) # a third metric is cut off at this width
f_col.metric("Faithfulness", f"{faith:.2f}")
r_col.metric("Answer relevancy", f"{rel:.2f}")
st.caption(
"Faithfulness: share of the answer's claims the pages support. "
"Answer relevancy: how directly it answers the question. Both 0–1. "
f"Scoring cost: ${scoring:.4f}."
)
elif not answered(a):
st.caption("No answer, so nothing to score.")
def show_turn(t: Turn) -> tuple:
"""One row: answer left, metrics right. Returns the right column."""
left, right = st.columns([3, 2])
with left:
show_answer(t)
with right:
show_metrics(t)
st.divider()
return right
st.set_page_config(page_title="FDA Document Q&A", layout="wide")
state = st.session_state
state.setdefault("turns", [])
state.setdefault("n_queries", 0)
clicked = None
with st.sidebar:
api_key = st.text_input(
"Your Anthropic API key",
type="password",
key="api_key",
help="Used for this session's Claude calls only; never stored.",
)
st.subheader("About")
st.markdown(
"Ask questions about FDA drug regulation. Answers come only from "
"52 public FDA documents (2,313 pages): presentations by FDA's drug "
"center from 2017 to 2024 and two books, on topics such as generic "
"drug approvals, new drugs, biosimilars, opioids and drug shortages."
)
st.markdown(
"Every answer cites the document and page it came from. If the "
"documents don't cover a question, the app says so instead of guessing."
)
st.markdown(
"Each answer is scored live with RAGAS, using Claude Haiku as the judge."
)
st.caption(
"Not medical or regulatory advice. Documents: ViDoRe V3 Pharmaceuticals "
"dataset, CC BY 4.0."
)
st.subheader("Try")
for q in EXAMPLE_QUESTIONS:
if st.button(q, use_container_width=True):
clicked = q
st.title("FDA Document Q&A")
st.caption("Type a question below, or pick an example in the sidebar.")
for turn in state.turns:
show_turn(turn)
question = st.chat_input("Ask about the FDA documents") or clicked
if question and not api_key:
st.warning("Enter your Anthropic API key in the sidebar first.")
elif question and state.n_queries >= SESSION_QUERY_CAP:
st.warning(f"This session's limit of {SESSION_QUERY_CAP} questions is used up.")
elif question:
state.n_queries += 1
history = [t.question for t in state.turns if t.error is None]
with st.spinner("Searching and answering…"):
turn = run_turn(question, history, api_key)
state.turns.append(turn)
right = show_turn(turn)
if not turn.error and answered(turn.answer):
with right, st.spinner("Scoring the answer…"):
live = live_scores(
question, turn.answer, turn.hits, api_key, get_embeddings()
)
state.turns[-1] = replace(turn, live=live)
st.rerun()
st.sidebar.caption(f"Questions this session: {state.n_queries} of {SESSION_QUERY_CAP}")