bddinh3's picture
Upload 10 files
dda5804 verified
"""
Streamlit front-end for the AI Litigation Tracker.
This app:
- Loads case-level summaries (and metadata) from data/summaries.csv
- Lets users filter and explore AI-related litigation
- Integrates optional RAG backends for case-specific and global Q&A
"""
import os
from typing import Optional
import calendar
import pandas as pd
import streamlit as st
# ============================================================
# Config
# ============================================================
APP_TITLE = "AI Litigation Tracker"
CSV_PATH = os.path.join(os.path.dirname(__file__), "data", "summaries.csv")
LOGO_DIR = os.path.join(os.path.dirname(__file__), "logos")
LAWFARE_LOGO_PATH = os.path.join(LOGO_DIR, "lawfare_logo.png")
VAILL_LOGO_PATH = os.path.join(LOGO_DIR, "vaill_logo.png")
COURTLISTENER_LOGO_PATH = os.path.join(LOGO_DIR, "court_listener_logo.png")
# Try to load RAG chains (optional backends)
try:
from rag.chains import case_specific_qa, global_qa, ping_backends
chains_ok = True
except Exception as e: # pragma: no cover - only hit when backends misconfigured
chains_ok = False
chains_import_error = e
st.set_page_config(
page_title=APP_TITLE,
layout="wide",
page_icon="⚖️",
)
# ============================================================
# Data
# ============================================================
@st.cache_data(show_spinner=False)
def load_summaries() -> pd.DataFrame:
"""
Load the case summaries CSV and normalize columns/types.
Expected base columns:
- case_name
- filing_date
- docket_number
- summary
Optional metadata columns (if present) are parsed and kept for UI:
- last_updated (legacy)
- latest_update (canonical last activity date; YYYY-MM-DD recommended)
- jurisdiction
- court_id
- courtlistener_url
"""
if not os.path.exists(CSV_PATH):
raise FileNotFoundError(f"Missing summaries CSV at {CSV_PATH}")
df = pd.read_csv(CSV_PATH)
expected_cols = ["case_name", "filing_date", "docket_number", "summary"]
missing = [c for c in expected_cols if c not in df.columns]
if missing:
raise ValueError(f"summaries.csv missing columns: {missing}")
# Normalize core text columns to strings
for c in expected_cols:
df[c] = df[c].fillna("").astype(str)
optional_cols = [
"last_updated",
"latest_update",
"jurisdiction",
"court_id",
"courtlistener_url",
]
for c in optional_cols:
if c in df.columns:
df[c] = df[c].fillna("").astype(str)
# Parse filing date for correct sorting/filtering
df["filing_date_dt"] = pd.to_datetime(df["filing_date"], errors="coerce")
# Parse latest_update into a datetime column if present
# (this represents the best-guess "last activity" date for the case)
if "latest_update" in df.columns:
df["latest_update_dt"] = pd.to_datetime(df["latest_update"], errors="coerce")
elif "last_updated" in df.columns:
# Fallback for legacy naming: treat last_updated as latest_update
df["latest_update_dt"] = pd.to_datetime(df["last_updated"], errors="coerce")
return df
def refresh_data() -> None:
"""Clear the cached summaries so the next load() call re-reads from disk."""
load_summaries.clear()
def pretty_date(dt: pd.Timestamp) -> str:
"""
Format a Timestamp as 'Month D, YYYY' (e.g. 'August 8, 2025').
Returns 'N/A' for NaT values.
"""
if pd.isna(dt):
return "N/A"
# Month name + non-padded day, e.g. "August 8, 2025"
return f"{dt.strftime('%B')} {dt.day}, {dt.year}"
# ============================================================
# Chat helpers
# ============================================================
def ensure_chat_state(key: str) -> None:
"""Initialize a session_state list for a given chat key if missing."""
if key not in st.session_state:
st.session_state[key] = [] # list of {role, content}
def replay_chat(key: str) -> None:
"""Replay all messages for a given chat key into the Streamlit chat UI."""
for msg in st.session_state.get(key, []):
with st.chat_message(msg["role"]):
st.write(msg["content"])
def add_message(key: str, role: str, content: str) -> None:
"""Append a new message to the stored chat transcript."""
st.session_state[key].append({"role": role, "content": content})
# ============================================================
# CSS (Lawfare-centered color scheme)
# ============================================================
GLOBAL_CSS = """
<style>
:root {
--primary-color: #006a72; /* Lawfare teal */
--primary-dark: #00555b;
--primary-soft: #e0f2f3;
--border-color: #e1e8ed;
--bg-soft: #f8fafc;
--text-main: #2c3e50;
--text-muted: #64748b;
}
/* Main app background + container */
.stApp {
background-color: var(--bg-soft);
}
.main .block-container {
background-color: #ffffff;
padding-top: 2rem;
max-width: 1200px;
}
/* Sidebar container */
section[data-testid="stSidebar"] {
background-color: #ffffff !important;
color: var(--text-main) !important;
border-right: 1px solid var(--border-color);
min-width: 360px !important;
width: 360px !important;
}
/* Sidebar title */
.sidebar-title {
font-weight: 700;
font-size: 1.05rem;
margin-top: 0.5rem;
color: #12243a;
}
/* Hero + sections */
.hero-section {
text-align: center;
padding: 3rem 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.hero-section h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-main);
margin-bottom: 1rem;
line-height: 1.2;
}
.hero-section .subtitle {
font-size: 1.2rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
.what-is-section {
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border: 1px solid var(--border-color);
margin-bottom: 2rem;
padding: 2rem;
}
.what-is-section h2 {
font-size: 2rem;
font-weight: 700;
color: var(--text-main);
margin-bottom: 1rem;
}
.what-is-section p {
font-size: 1.05rem;
color: #4a5568;
line-height: 1.7;
margin: 0;
}
/* Generic section container + header */
.section-container {
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.section-header {
background: var(--bg-soft);
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid var(--border-color);
border-radius: 10px 10px 0 0;
}
.section-header h2 {
margin: 0 0 0.5rem 0;
color: var(--text-main);
font-size: 1.5rem;
font-weight: 600;
}
.section-header p {
margin: 0;
color: var(--text-muted);
font-size: 1rem;
}
.section-content {
padding: 2rem;
}
/* Metric cards */
.metric-card {
background: var(--bg-soft);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.metric-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
color: var(--text-main);
}
.metric-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
/* Tool description bar */
.tool-description {
background: var(--primary-soft);
border-left: 4px solid var(--primary-color);
padding: 1rem;
border-radius: 0 6px 6px 0;
margin-bottom: 1.5rem;
}
.tool-description p {
margin: 0;
color: var(--primary-dark);
}
/* Info grid (not used yet but kept for consistency) */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.info-card {
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border: 1px solid var(--border-color);
padding: 2rem;
}
.info-card h3 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-main);
margin-bottom: 1rem;
}
.info-card p, .info-card li {
color: #4a5568;
line-height: 1.6;
}
.info-card ul {
list-style: none;
padding: 0;
}
.info-card li {
margin-bottom: 0.75rem;
}
/* Tabs style */
.stTabs [data-baseweb="tab-list"] {
background: var(--bg-soft);
border-radius: 8px;
padding: 0.25rem;
border: 1px solid var(--border-color);
}
.stTabs [data-baseweb="tab"] {
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
color: var(--text-muted) !important;
border-radius: 6px !important;
margin: 0 0.25rem !important;
background: transparent !important;
border: none !important;
}
.stTabs [data-baseweb="tab"]:hover {
background: var(--primary-soft) !important;
color: var(--text-main) !important;
}
.stTabs [data-baseweb="tab"][aria-selected="true"] {
background: white !important;
color: var(--text-main) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
/* Litigation table wrapper (scrollable) */
.cases-table-wrapper {
max-height: 600px;
overflow-y: auto;
border-radius: 8px;
border: 1px solid var(--border-color);
}
/* Litigation table styling (HTML table) */
.cases-table {
width: 100%;
border-collapse: collapse;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.4;
background: white;
}
.cases-table thead th {
text-align: left;
font-weight: 700;
padding: 10px 12px;
border-bottom: 2px solid #1f2b3a;
background: white;
}
.cases-table tbody td {
vertical-align: top;
padding: 10px 12px;
border-bottom: 1px solid #eef1f6;
background: white;
}
.cases-table tbody tr:nth-child(even) td {
background: #f9fafb;
}
.cases-table tbody tr:hover td {
background: #f1f5f9;
}
/* Data editor / dataframe styling (if used elsewhere) */
div[data-testid="stDataFrame"] {
background: white !important;
}
div[data-testid="stDataFrame"] > div {
background: white !important;
}
div[data-testid="stDataFrame"] table {
background: white !important;
}
div[data-testid="stDataFrame"] thead {
background: var(--bg-soft) !important;
}
div[data-testid="stDataFrame"] tbody {
background: white !important;
}
div[data-testid="stDataFrame"] th {
background-color: var(--bg-soft) !important;
color: var(--text-main) !important;
border-bottom: 1px solid var(--border-color) !important;
}
div[data-testid="stDataFrame"] td {
background-color: white !important;
color: var(--text-main) !important;
border-bottom: 1px solid var(--border-color) !important;
white-space: normal !important;
word-wrap: break-word !important;
}
.stDataEditor {
background-color: white !important;
}
.stDataEditor > div {
background-color: white !important;
}
.stDataEditor table {
background-color: white !important;
}
.stDataEditor th, .stDataEditor td {
background-color: white !important;
color: var(--text-main) !important;
}
/* Download button (primary color) */
.stDownloadButton > button {
background-color: var(--primary-color) !important;
color: white !important;
border-radius: 6px !important;
padding: 0.6rem 1.2rem !important;
font-weight: 600 !important;
border: none !important;
}
.stDownloadButton > button > div > p,
.stDownloadButton > button > span,
.stDownloadButton button span,
.stDownloadButton button p {
color: white !important;
}
.stDownloadButton > button:hover {
background-color: var(--primary-dark) !important;
}
/* General text color */
.stApp, .stApp p, .stApp span, .stApp div, .stApp label {
color: var(--text-main);
}
.sidebar-logo-stack {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0;
}
section[data-testid="stSidebar"] img {
display: block;
max-width: 95%;
height: auto;
margin: 0 auto !important;
padding: 0 !important;
}
</style>
"""
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
# ============================================================
# Table renderer (full summary, scrollable)
# ============================================================
def render_cases_table(df: pd.DataFrame) -> None:
"""
Render an interactive Streamlit dataframe for the filtered cases.
Includes:
- Lawsuit name
- Jurisdiction and court (if available)
- Docket number
- Filing date
- Latest activity date (if available)
- Short summary
- CourtListener URL (if available)
"""
if df.empty:
st.warning("No cases match the selected filters.")
return
display_df = df.copy()
# Drop raw string date fields in favor of datetime columns used for display.
for col in ["filing_date", "latest_update", "last_updated"]:
if col in display_df.columns:
display_df = display_df.drop(columns=[col])
# ---- Column mapping / ordering ----
column_mapping = {
"case_name": "Lawsuit",
"jurisdiction": "Jurisdiction",
"court_id": "Court",
"summary": "Summary",
"docket_number": "Docket Number",
"filing_date_dt": "Date Filed",
"latest_update_dt": "Most Recent Activity",
"courtlistener_url": "CourtListener URL",
}
# Only keep columns that actually exist in the dataframe
base_cols = [
"case_name",
"jurisdiction",
"court_id",
"docket_number",
"filing_date_dt",
"latest_update_dt",
"summary",
"courtlistener_url",
]
existing_base_cols = [c for c in base_cols if c in display_df.columns]
# Anything else in the df gets appended at the end
extra_cols = [c for c in display_df.columns if c not in existing_base_cols]
display_columns = existing_base_cols + extra_cols
display_df = display_df[display_columns].copy()
display_df = display_df.rename(columns={k: v for k, v in column_mapping.items() if k in display_df.columns})
# Streamlit's dataframe will turn HTTP URLs into clickable links automatically.
st.dataframe(
display_df,
hide_index=True,
width="stretch",
column_config={
"Date Filed": st.column_config.DateColumn(
"Date Filed",
format="MMMM D, YYYY",
),
"Most Recent Activity": st.column_config.DateColumn(
"Most Recent Activity",
format="MMMM D, YYYY",
),
"Summary": st.column_config.TextColumn(
"Summary",
width="large",
),
# Optional: make the link column a bit wider
"CourtListener URL": st.column_config.TextColumn(
"CourtListener URL",
width="medium",
),
},
)
# ---- CSV download (export the exact view the user sees) ----
csv = display_df.to_csv(index=False)
st.download_button(
label="Download Table as CSV",
data=csv,
file_name="ai_litigation_cases.csv",
mime="text/csv",
)
# ============================================================
# Load data
# ============================================================
df: Optional[pd.DataFrame] = None
try:
df = load_summaries()
except Exception as e:
st.error(f"Unable to load summaries: {e}")
# ============================================================
# Sidebar (dynamic filters like tracker)
# ============================================================
with st.sidebar:
st.markdown('<div class="sidebar-logo-stack">', unsafe_allow_html=True)
if os.path.exists(LAWFARE_LOGO_PATH):
st.image(LAWFARE_LOGO_PATH, width='stretch')
if os.path.exists(VAILL_LOGO_PATH):
st.image(VAILL_LOGO_PATH, width='stretch')
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("### Filter Controls")
# Defaults in case df is empty
sidebar_q = ""
date_mask = None
if df is not None and not df.empty:
# Basic full-text search over case_name and docket_number
sidebar_q = st.text_input(
"Search",
placeholder="case name or docket…",
)
# ---------------- Date Range ----------------
st.markdown("#### Date Range")
min_dt = df["filing_date_dt"].min()
max_dt = df["filing_date_dt"].max()
if pd.isna(min_dt) or pd.isna(max_dt):
st.caption("No valid filing dates available for filtering.")
else:
years = sorted(
df["filing_date_dt"].dropna().dt.year.unique().tolist()
)
month_names = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
]
date_filter_mode = st.radio(
"Filter by:",
options=["No Date Filter", "Year Only", "Year & Month"],
index=0,
)
date_filter_summary = ""
date_filter_count = None
if date_filter_mode == "Year Only":
selected_years = st.multiselect(
"Select Years:",
options=years,
default=years,
)
if selected_years:
year_series = df["filing_date_dt"].dt.year
date_mask = year_series.isin(selected_years)
date_filter_summary = (
"Filtering: " + ", ".join(str(y) for y in selected_years)
)
date_filter_count = int(date_mask.sum())
else:
# no years picked → no rows
date_mask = df["filing_date_dt"].notna() & False
date_filter_summary = "No years selected."
date_filter_count = 0
elif date_filter_mode == "Year & Month":
# ---- Row 1: From / To year ----
col_y1, col_y2 = st.columns(2)
with col_y1:
from_year = st.selectbox(
"From Year:",
options=years,
index=0,
)
with col_y2:
to_year = st.selectbox(
"To Year:",
options=years,
index=len(years) - 1,
)
# ---- Row 2: From / To month ----
col_m1, col_m2 = st.columns(2)
with col_m1:
from_month_name = st.selectbox(
"From Month:",
options=month_names,
index=0,
)
with col_m2:
to_month_name = st.selectbox(
"To Month:",
options=month_names,
index=11,
)
from_month = month_names.index(from_month_name) + 1
to_month = month_names.index(to_month_name) + 1
# Ensure end is not before start
if (to_year, to_month) < (from_year, from_month):
from_year, to_year = to_year, from_year
from_month, to_month = to_month, from_month
from_month_name, to_month_name = to_month_name, from_month_name
# Build mask: year*100 + month allows a clean between() filter
date_vals = (
df["filing_date_dt"].dt.year * 100
+ df["filing_date_dt"].dt.month
)
start_val = from_year * 100 + from_month
end_val = to_year * 100 + to_month
date_mask = date_vals.between(start_val, end_val)
# Use abbreviated month names in the summary, like "Jan 2024 - Nov 2025"
from_abbr = calendar.month_abbr[from_month]
to_abbr = calendar.month_abbr[to_month]
date_filter_summary = (
f"Filtering: {from_abbr} {from_year} - {to_abbr} {to_year}"
)
date_filter_count = int(date_mask.sum())
if date_filter_mode != "No Date Filter":
if date_filter_summary:
st.success(date_filter_summary)
if date_filter_count is not None:
st.info(f"{date_filter_count} cases with dates in range")
else:
sidebar_q = ""
date_mask = None
# ---- Developer tools -----------------------------------
st.markdown("---")
with st.expander("Developer tools (advanced)", expanded=False):
st.markdown("#### Backend Status")
if chains_ok:
try:
status = ping_backends()
st.write(f"OpenAI: {'✅' if status.get('openai') else '⚠️'}")
st.write(f"Pinecone: {'✅' if status.get('pinecone') else '⚠️'}")
if status.get("index_name"):
st.write(f"Index: `{status['index_name']}`")
except Exception as e:
st.warning(f"Health check error: {e}")
else:
st.error("RAG chains import failed.")
st.exception(chains_import_error)
# ============================================================
# Hero + description
# ============================================================
st.markdown(
"""
<div class="hero-section">
<h1>Tracking and Analyzing AI-Related Litigation</h1>
<p class="subtitle">
Explore lawsuits involving artificial intelligence across U.S. courts.
Use search and filters to browse cases, read summaries, and run AI-powered Q&A
on individual matters or the full corpus.
</p>
</div>
""",
unsafe_allow_html=True,
)
st.markdown(
"""
<div class="what-is-section">
<h2>What is the AI Litigation Tracker?</h2>
<p>
This tracker is a centralized, user-friendly platform for monitoring AI-related lawsuits
across the United States. It helps users quickly see where and how AI issues are being
litigated, understand the posture of each case, and compare patterns across jurisdictions.
</p>
</div>
""",
unsafe_allow_html=True,
)
# ============================================================
# Apply filters to build filtered_df
# ============================================================
if df is None or df.empty:
st.warning("No cases available yet.")
st.stop()
filtered_df = df.copy()
# Text search
if sidebar_q:
q_low = sidebar_q.lower()
filtered_df = filtered_df[
filtered_df["case_name"].str.lower().str.contains(q_low, na=False)
| filtered_df["docket_number"].str.lower().str.contains(q_low, na=False)
]
if date_mask is not None:
filtered_df = filtered_df[date_mask]
# Default sort: newest filing first
if "filing_date_dt" in filtered_df.columns:
filtered_df = filtered_df.sort_values(
"filing_date_dt", ascending=False, na_position="last"
)
# ============================================================
# Tabs (Cases Explorer, Case QA, Global QA)
# ============================================================
tab1, tab2, tab3 = st.tabs(["Cases Explorer", "Case Q&A", "Global Q&A"])
# === TAB 1: Cases Explorer (table view) ======================
with tab1:
st.markdown(
"""
<div class="tool-description">
<p>Navigate and filter AI litigation using search and sidebar filters. View case details,
summaries, filing dates, latest activity dates, and export your view
as a CSV for further analysis.</p>
</div>
""",
unsafe_allow_html=True,
)
# Overview metrics (three KPIs)
st.markdown('<div class="section-container">', unsafe_allow_html=True)
st.markdown(
'<div class="section-header"><h2>Database Overview</h2>'
'<p>Current statistics for the filtered case set</p></div>',
unsafe_allow_html=True,
)
st.markdown('<div class="section-content">', unsafe_allow_html=True)
total_cases = len(filtered_df)
# Distinct jurisdictions (fall back to court_id if jurisdiction is empty)
if "jurisdiction" in filtered_df.columns:
juris = (
filtered_df["jurisdiction"]
.astype(str)
.str.strip()
.replace("", pd.NA)
.dropna()
)
else:
juris = pd.Series([], dtype="object")
# If jurisdiction is effectively empty, fall back to court_id
if juris.empty and "court_id" in filtered_df.columns:
juris = (
filtered_df["court_id"]
.astype(str)
.str.strip()
.replace("", pd.NA)
.dropna()
)
num_jurisdictions = juris.nunique()
# Most recent activity (preferred) or most recent filing as fallback
recent_label = "Most Recent Case Activity"
recent_value = "N/A"
if (
"latest_update_dt" in filtered_df.columns
and not filtered_df["latest_update_dt"].dropna().empty
):
recent_value = pretty_date(filtered_df["latest_update_dt"].max())
elif (
"filing_date_dt" in filtered_df.columns
and not filtered_df["filing_date_dt"].dropna().empty
):
recent_label = "Most Recent Filing"
recent_value = pretty_date(filtered_df["filing_date_dt"].max())
c1, c2, c3 = st.columns(3)
with c1:
st.markdown(
f'<div class="metric-card"><h3>{total_cases}</h3>'
f'<p>Total Cases in View</p></div>',
unsafe_allow_html=True,
)
with c2:
st.markdown(
f'<div class="metric-card"><h3>{num_jurisdictions}</h3>'
f'<p>Jurisdictions in View</p></div>',
unsafe_allow_html=True,
)
with c3:
st.markdown(
f'<div class="metric-card"><h3>{recent_value}</h3>'
f'<p>{recent_label}</p></div>',
unsafe_allow_html=True,
)
st.markdown('</div></div>', unsafe_allow_html=True)
# Table itself
st.markdown('<div class="section-container">', unsafe_allow_html=True)
st.markdown(
'<div class="section-header"><h2>Litigation Database</h2>'
'<p>Comprehensive listing of AI-related lawsuits in the filtered view</p></div>',
unsafe_allow_html=True,
)
st.markdown('<div class="section-content">', unsafe_allow_html=True)
render_cases_table(filtered_df)
st.markdown('</div></div>', unsafe_allow_html=True)
# === TAB 2: Case Q&A =========================================
with tab2:
st.markdown(
"""
<div class="tool-description">
<p>Select a specific case to view its details and run AI-powered Q&A grounded in that
case's documents and metadata.</p>
</div>
""",
unsafe_allow_html=True,
)
st.markdown('<div class="section-container">', unsafe_allow_html=True)
st.markdown(
'<div class="section-header"><h2>Case Details & Q&A</h2>'
'<p>Choose a docket and ask focused questions about that case</p></div>',
unsafe_allow_html=True,
)
st.markdown('<div class="section-content">', unsafe_allow_html=True)
if df is None or df.empty:
st.info("No cases available yet.")
st.markdown('</div></div>', unsafe_allow_html=True)
else:
options = df.sort_values("case_name")[["docket_number", "case_name"]]
selected_docket = st.selectbox(
"Select a case",
options=options["docket_number"],
format_func=lambda d: options.loc[
options["docket_number"] == d, "case_name"
].iloc[0],
placeholder="Choose a case",
)
if selected_docket:
row = df[df["docket_number"] == selected_docket].iloc[0]
latest_update_dt = row.get("latest_update_dt", pd.NaT)
latest_update_str = pretty_date(latest_update_dt)
# Summary card
with st.container(border=True):
st.markdown(
f"**Lawsuit:** {row['case_name']} \n"
f"**Docket Number:** `{row['docket_number']}` \n"
f"**Date Filed:** {row.get('filing_date', 'Unknown')}"
)
# Optional metadata if present
jurisdiction = row.get("jurisdiction", "") or None
court_id = row.get("court_id", "") or None
courtlistener_url = row.get("courtlistener_url", "") or None
if jurisdiction:
st.markdown(f"**Jurisdiction:** {jurisdiction}")
if court_id:
st.markdown(f"**Court:** `{court_id}`")
if latest_update_str != "N/A":
st.markdown(f"**Latest Activity:** {latest_update_str}")
if courtlistener_url:
st.markdown(
f"[Open in CourtListener]({courtlistener_url})",
unsafe_allow_html=False,
)
st.markdown("**Summary**")
st.write(row["summary"])
st.markdown("---")
if not chains_ok:
st.error(
"RAG backends are not available yet. Check rag/chains.py & Pinecone."
)
else:
state_key = f"chat_case::{row['docket_number']}"
ensure_chat_state(state_key)
colA, _ = st.columns([1, 5])
with colA:
if st.button("Clear chat", key="clear_case_chat"):
st.session_state[state_key] = []
st.toast("Cleared case chat.")
# Full-width caption directly under the button (not in a column)
st.caption("Ask questions grounded **only in this case**.")
replay_chat(state_key)
prompt = st.chat_input(f"Ask about {row['case_name']}…")
if prompt:
add_message(state_key, "user", prompt)
with st.chat_message("assistant"):
try:
ans = case_specific_qa(
prompt,
docket_number=row["docket_number"],
case_name=row["case_name"],
)
except Exception as e:
ans = f"Error answering case-specific question: {e}"
add_message(state_key, "assistant", ans)
st.write(ans)
st.markdown('</div></div>', unsafe_allow_html=True)
# === TAB 3: Global Q&A =======================================
with tab3:
st.markdown(
"""
<div class="tool-description">
<p>Ask questions across the full litigation corpus using RAG-based search over all
tracked cases.</p>
</div>
""",
unsafe_allow_html=True,
)
st.markdown('<div class="section-container">', unsafe_allow_html=True)
st.markdown(
'<div class="section-header"><h2>Global Q&A Across All Cases</h2>'
'<p>Explore broader patterns, themes, and trends in AI-related litigation</p></div>',
unsafe_allow_html=True,
)
st.markdown('<div class="section-content">', unsafe_allow_html=True)
if not chains_ok:
st.error(
"RAG backends are not available yet. Check rag/chains.py & Pinecone."
)
else:
state_key = "chat_global"
ensure_chat_state(state_key)
colA, _ = st.columns([1, 5])
with colA:
if st.button("Clear chat", key="clear_global_chat"):
st.session_state[state_key] = []
st.toast("Cleared global chat.")
# Full-width caption directly under the button
st.caption("Ask questions across the full litigation corpus (RAG).")
replay_chat(state_key)
prompt = st.chat_input("Ask a question across all cases…")
if prompt:
add_message(state_key, "user", prompt)
with st.chat_message("assistant"):
try:
ans = global_qa(prompt, top_k=4)
except Exception as e:
ans = f"Error answering global question: {e}"
add_message(state_key, "assistant", ans)
st.write(ans)
# Optional: show top retrieved hits if vectorstore is available
try:
from vectorstore.cases_vectorstore import query_global
hits = query_global(prompt, top_k=4)
if hits:
st.markdown("**Top retrieved cases:**")
for h in hits:
st.markdown(
f"- {h.get('case_name','?')} "
f"({h.get('docket_number','?')} · score={h.get('score',0):.3f})"
)
except Exception:
# If vectorstore isn't available, we still return the LLM answer.
pass
st.markdown('</div></div>', unsafe_allow_html=True)
# ============================================================
# Footer with credits
# ============================================================
import base64
def encode_image(path: str) -> str:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
st.markdown("---")
if os.path.exists(COURTLISTENER_LOGO_PATH):
img_b64 = encode_image(COURTLISTENER_LOGO_PATH)
footer_html = f"""
<div style="text-align: center; margin-top: 1.5rem;">
<p style="color:#2c3e50; margin-bottom: 0.5rem;">
© 2025 AI Litigation Tracker · Vanderbilt AI Law Lab × Lawfare
</p>
<img src="data:image/png;base64,{img_b64}"
style="width:140px; opacity:0.95; margin-bottom:6px;" />
<p style="font-size:0.85rem; color:#6b7280; max-width:400px; margin:0 auto;">
Court data provided by <strong>CourtListener</strong>,
a project of <strong>Free Law Project</strong>.
</p>
</div>
"""
st.markdown(footer_html, unsafe_allow_html=True)