{}
Federal Bills
{}
Bills This Year
{}
Last Updated
{}
State Bills
{}
Most Active State
N/A
Most Active State
{}
Last Updated
{}
Federal Bills
{}
State Bills
{}
Most Active State
N/A
Most Active State
{}
Last Updated
diff --git "a/huggingface_app.py" "b/huggingface_app.py" new file mode 100644--- /dev/null +++ "b/huggingface_app.py" @@ -0,0 +1,2459 @@ +# =============================================== +# VAILL AI Governance Bills Tracker +# =============================================== +# Table of Contents (functions): +# 1. get_qa_llm() +# 2. get_embeddings() +# 3. get_text_splitter() +# 4. create_bill_documents() +# 5. create_vectorstore_from_bills() +# 6. compare_bills_with_rag() +# 7. answer_bill_question() +# 8. load_eu_ai_act_vectorstore() +# 9. get_eu_vectorstore_info() +# 10. compare_bill_with_eu_ai_act() +# 11. load_bill_reports() +# 12. get_bill_report() +# 13. load_bill_summaries() +# 14. load_bill_suggested_questions() +# 15. get_bill_suggested_questions() +# 16. get_bill_summary() +# 17. load_and_process_data() +# 18. load_openai_api_key() +# 19. display_bill_details() +# 20. get_last_updated_date() +# 21. extract_iapp_subcategories() +# 22. format_date() +# 23. load_us_states_geojson() +# 24. _bill_label() +# 25. _group_to_ul() +# 26. create_bill_options() +# ----------------------------------------------- +# Section markers: search for '==== SECTION' lines to jump around. +# =============================================== + +# ==== SECTION: Original file begins below (unchanged) ==== +#!/usr/bin/env python3 +# scripts/app.py + +""" +Streamlit visualization for the AI Governance Bills Tracker. + +Displays an interactive dashboard of AI-related bills from known_bills_visualize.json, including +a table, map, filters, Q&A, plan comparison, summary generation, and CSV download functionality. +""" + +import streamlit as st +import pandas as pd +import time +from streamlit_folium import st_folium +import folium +import json +from pathlib import Path +import os +import dotenv +import io +import logging +from datetime import datetime, date, timedelta +from constants import IAPP_CATEGORIES +import requests +import html +from langchain_openai import ChatOpenAI +from langchain.prompts import ChatPromptTemplate +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_openai import OpenAIEmbeddings +from langchain_community.vectorstores import FAISS +from langchain.schema import Document +from langchain.chains import create_retrieval_chain +from langchain.chains.combine_documents import create_stuff_documents_chain +from langchain.prompts import ChatPromptTemplate +import pickle +import os +from datasets import load_dataset +import tempfile +import shutil + +dotenv.load_dotenv() + +# Create logs directory if it doesn't exist +os.makedirs("app_logs", exist_ok=True) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(), logging.FileHandler("app_logs/visualize.log")], +) + +logger = logging.getLogger(__name__) + +# Hugging Face dataset configuration +HF_DATASET_NAME = "VAILL/legislation-tracker-data" +HF_DATA_FOLDER = "data" + +@st.cache_data +def load_from_hf_dataset(file_path: str): + """Load a file from the Hugging Face dataset repository.""" + try: + # Load the dataset + dataset = load_dataset(HF_DATASET_NAME, data_files=f"{HF_DATA_FOLDER}/{file_path}") + + # Get the first (and only) split + split_name = list(dataset.keys())[0] + data = dataset[split_name] + + # Convert to the appropriate format based on file type + if file_path.endswith('.json'): + # For JSON files, return the data as a list of dictionaries + return data.to_list() + else: + # For other files, return the raw data + return data + + except Exception as e: + logger.error(f"Error loading {file_path} from Hugging Face dataset: {e}") + return None + +@st.cache_data +def load_file_from_hf_dataset(file_path: str): + """Load a file from the Hugging Face dataset repository and return as bytes.""" + try: + # Load the dataset + dataset = load_dataset(HF_DATASET_NAME, data_files=f"{HF_DATA_FOLDER}/{file_path}") + + # Get the first (and only) split + split_name = list(dataset.keys())[0] + data = dataset[split_name] + + # For binary files, we need to handle them differently + # This is a simplified approach - you might need to adjust based on your specific needs + return data + + except Exception as e: + logger.error(f"Error loading {file_path} from Hugging Face dataset: {e}") + return None + +@st.cache_data +def download_vectorstore_from_hf(): + """Download the EU AI Act vectorstore from Hugging Face dataset to a temporary directory.""" + try: + # Create a temporary directory + temp_dir = tempfile.mkdtemp() + vectorstore_temp_path = os.path.join(temp_dir, "eu_ai_act_vectorstore") + + # Store the temp directory path for cleanup + st.session_state['vectorstore_temp_dir'] = temp_dir + + # Create the vectorstore directory + os.makedirs(vectorstore_temp_path, exist_ok=True) + + # List of expected vectorstore files + vectorstore_files = [ + "index.faiss", + "index.pkl", + "metadata.pickle" + ] + + # Download each file from the Hugging Face dataset + for filename in vectorstore_files: + try: + # Load the specific file from the dataset + dataset = load_dataset(HF_DATASET_NAME, data_files=f"{HF_DATA_FOLDER}/eu_ai_act_vectorstore/{filename}") + + # Get the first (and only) split + split_name = list(dataset.keys())[0] + data = dataset[split_name] + + if len(data) > 0 and 'bytes' in data[0]: + file_path = os.path.join(vectorstore_temp_path, filename) + with open(file_path, 'wb') as f: + f.write(data[0]['bytes']) + logger.info(f"Downloaded {filename}") + else: + logger.warning(f"Could not find {filename} in dataset") + + except Exception as e: + logger.warning(f"Error downloading {filename}: {e}") + + # Check if we have the essential files + index_faiss_path = os.path.join(vectorstore_temp_path, "index.faiss") + index_pkl_path = os.path.join(vectorstore_temp_path, "index.pkl") + + if os.path.exists(index_faiss_path) and os.path.exists(index_pkl_path): + logger.info(f"✅ EU AI Act vectorstore downloaded to {vectorstore_temp_path}") + return vectorstore_temp_path + else: + logger.error("Essential vectorstore files not found") + return None + + except Exception as e: + logger.error(f"Error downloading vectorstore from Hugging Face dataset: {e}") + return None + +# Page configuration +st.set_page_config( + layout="wide", + page_title="VAILL AI Governance Legislation Tracker", + page_icon="⚖️" +) + +# Custom CSS for clean, section-based layout +st.markdown(""" + +""", unsafe_allow_html=True) + +# Hero Section +st.markdown(""" +
A resource from the Vanderbilt AI Law Lab (VAILL) to help policymakers, researchers, and the public stay informed about the evolving landscape of AI regulation in the United States.
+ This tracker is a centralized, user-friendly platform for monitoring artificial intelligence (AI) governance legislation across the United States. As AI technology rapidly advances, it's becoming increasingly important to understand how different states are approaching its regulation. This tool aims to simplify the process of finding and comparing various state-level AI governance bills, their current statuses, and their key provisions. str:
+ """Answer a question about a specific bill using LangChain."""
+ try:
+ llm = get_qa_llm()
+
+ # Create the prompt template
+ qa_prompt = ChatPromptTemplate.from_template(
+ """You are a legislative analyst expert at interpreting AI governance bills.
+ A user has asked a question about a specific bill. Use the bill information
+ provided as JSON to answer their question accurately and comprehensively.
+
+ Guidelines:
+ - Answer based only on the information provided in the bill JSON
+ - Be specific and cite relevant sections when possible
+ - If the information isn't available in the bill, clearly state that
+ - Keep your answer focused and relevant to the question
+ - Use clear, accessible language
+ - Always include the legiscan link to the bill in your answer
+ - Format your answer as markdown
+
+ Bill JSON:
+ ```json
+ {bill_json}
+ ```
+
+ User Question: {question}
+
+ Please provide a detailed answer based on the bill information above.
+ """
+ )
+
+ # Convert timestamps and other non-serializable objects to strings
+ serializable_bill_data = {}
+ for key, value in bill_data.items():
+ try:
+ # Handle None/NaN values
+ if value is None:
+ serializable_bill_data[key] = None
+ elif isinstance(value, (int, float)) and pd.isna(value):
+ serializable_bill_data[key] = None
+ elif hasattr(value, 'strftime'): # Handle datetime/timestamp objects
+ serializable_bill_data[key] = value.strftime('%Y-%m-%d')
+ elif isinstance(value, (list, dict, str, int, float, bool)):
+ # These types are JSON serializable
+ serializable_bill_data[key] = value
+ else:
+ # Convert anything else to string
+ serializable_bill_data[key] = str(value)
+ except Exception:
+ # Fallback: convert to string or None
+ serializable_bill_data[key] = str(value) if value is not None else None
+
+ # Convert bill data to JSON string
+ bill_json = json.dumps(serializable_bill_data, ensure_ascii=False, indent=2)
+
+ # Create chain and invoke
+ chain = qa_prompt | llm
+ result = chain.invoke({
+ "bill_json": bill_json,
+ "question": question
+ })
+
+ # Extract content from result
+ answer = getattr(result, "content", str(result))
+ return answer
+
+ except Exception as e:
+ logger.error(f"Error in Q&A: {e}")
+ return f"Error processing question: {str(e)}"
+
+@st.cache_resource
+def load_eu_ai_act_vectorstore():
+ """Load the EU AI Act vectorstore from Hugging Face dataset."""
+ try:
+ # Download vectorstore from Hugging Face dataset
+ vectorstore_temp_path = download_vectorstore_from_hf()
+ if vectorstore_temp_path is None:
+ logger.warning("EU AI Act vectorstore not found in Hugging Face dataset")
+ return None, "Vectorstore not found in Hugging Face dataset. Please ensure it's uploaded to VAILL/legislation-tracker-data."
+
+ # Initialize embeddings
+ embeddings = get_embeddings()
+
+ # Load vectorstore
+ vectorstore = FAISS.load_local(
+ vectorstore_temp_path,
+ embeddings,
+ allow_dangerous_deserialization=True
+ )
+
+ logger.info(f"✅ EU AI Act vectorstore loaded successfully from Hugging Face dataset")
+ return vectorstore, None
+
+ except Exception as e:
+ error_msg = f"Error loading EU AI Act vectorstore: {str(e)}"
+ logger.error(error_msg)
+ return None, error_msg
+
+@st.cache_data
+def get_eu_vectorstore_info():
+ """Get information about the EU AI Act vectorstore from Hugging Face dataset."""
+ try:
+ # Load metadata from Hugging Face dataset
+ metadata_data = load_file_from_hf_dataset("eu_ai_act_vectorstore/metadata.pickle")
+ if metadata_data is not None:
+ # Convert the dataset to bytes and load with pickle
+ metadata_bytes = metadata_data[0]['bytes'] if isinstance(metadata_data, list) and len(metadata_data) > 0 else metadata_data
+ if metadata_bytes:
+ metadata = pickle.loads(metadata_bytes)
+ return metadata
+ return {"error": "Metadata not found in Hugging Face dataset"}
+ except Exception as e:
+ return {"error": str(e)}
+
+# Add this function before the existing comparison functions
+
+def compare_bill_with_eu_ai_act(bill_data, question):
+ """Compare a US bill with the EU AI Act using RAG approach."""
+ try:
+ # Load EU AI Act vectorstore
+ eu_vectorstore, error = load_eu_ai_act_vectorstore()
+ if eu_vectorstore is None:
+ return f"Error loading EU AI Act data: {error}"
+
+ # Create US bill vectorstore
+ us_vectorstore = create_vectorstore_from_bills([bill_data])
+
+ # Create retrievers
+ eu_retriever = eu_vectorstore.as_retriever(
+ search_type="similarity",
+ search_kwargs={"k": 4} # Get top 4 relevant EU sections
+ )
+
+ us_retriever = us_vectorstore.as_retriever(
+ search_type="similarity",
+ search_kwargs={"k": 3} # Get top 3 relevant US bill sections
+ )
+
+ # Get relevant documents from both sources
+ eu_docs = eu_retriever.get_relevant_documents(question)
+ us_docs = us_retriever.get_relevant_documents(question)
+
+ # Format bill information
+ bill_info = f"{bill_data.get('state', 'Unknown')} {bill_data.get('bill_number', 'Unknown')}: {bill_data.get('title', 'Unknown')}"
+
+ # Create the comparison prompt
+ comparison_prompt = ChatPromptTemplate.from_template("""You are a legal analyst expert at comparing AI governance frameworks between the US and EU.
+You have been provided with relevant excerpts from a US bill and the EU AI Act to answer a comparison question.
+
+IMPORTANT CONTEXT:
+- US Bill: {bill_info}
+- EU Framework: Regulation (EU) 2024/1689 on Artificial Intelligence (AI Act)
+
+Your task is to compare these regulatory frameworks based on the user's question, using the relevant excerpts provided below.
+
+Guidelines for your analysis:
+- Clearly distinguish between US and EU approaches
+- Highlight similarities and differences in regulatory philosophy
+- Be specific about provisions, definitions, and requirements from each framework
+- Note any gaps or areas not covered by either framework
+- Consider the different legal systems and enforcement mechanisms
+- Structure your response with clear sections for each framework
+- Conclude with a summary of key similarities, differences, and implications
+- Always include the legiscan link to the US bill in your response
+- Format your answer as markdown
+
+Relevant excerpts from the US Bill:
+{us_context}
+
+Relevant excerpts from the EU AI Act:
+{eu_context}
+
+User Question: {input}
+
+Please provide a comprehensive comparison analysis based on the excerpts above.
+""")
+
+ # Prepare context
+ us_context = "\n\n".join([doc.page_content for doc in us_docs])
+ eu_context = "\n\n".join([doc.page_content for doc in eu_docs])
+
+ # Get LLM and create chain
+ llm = get_qa_llm()
+ chain = comparison_prompt | llm
+
+ # Run the comparison
+ result = chain.invoke({
+ "input": question,
+ "bill_info": bill_info,
+ "us_context": us_context,
+ "eu_context": eu_context
+ })
+
+ # Extract content from result
+ answer = getattr(result, "content", str(result))
+
+ # Add source information
+ answer += "\n\n---\n\n**Sources used in this analysis:**\n\n"
+ answer += f"**US Bill:** {bill_info}\n"
+ answer += f"**EU Framework:** EU AI Act (Regulation 2024/1689)\n"
+
+ return answer
+
+ except Exception as e:
+ logger.error(f"Error in EU comparison: {e}")
+ return f"Error during EU comparison analysis: {str(e)}"
+
+
+@st.cache_data
+def load_bill_reports() -> dict:
+ """Load pre-generated bill reports from Hugging Face dataset."""
+ try:
+ reports_data = load_from_hf_dataset("bill_reports.json")
+ if reports_data is not None:
+ # Convert to dict with bill_id as key
+ reports = {report['bill_id']: report['report_markdown'] for report in reports_data}
+ logger.info(f"Loaded {len(reports)} pre-generated reports from Hugging Face dataset")
+ return reports
+ else:
+ logger.warning("Reports file not found in Hugging Face dataset")
+ return {}
+ except Exception as e:
+ logger.error(f"Error loading reports from Hugging Face dataset: {e}")
+ return {}
+
+def get_bill_report(bill_data, reports_cache):
+ """Get report for a bill from cache or return message."""
+ bill_id = str(bill_data.get('bill_id', ''))
+
+ if bill_id in reports_cache:
+ return reports_cache[bill_id]
+ else:
+ return "No pre-generated report available for this bill."
+
+reports_cache = load_bill_reports()
+
+@st.cache_data
+def load_bill_summaries() -> dict:
+ """Load pre-generated bill summaries from Hugging Face dataset."""
+ try:
+ summaries = load_from_hf_dataset("bill_summaries.json")
+ if summaries is not None:
+ logger.info(f"Loaded {len(summaries)} pre-generated summaries from Hugging Face dataset")
+ return summaries
+ else:
+ logger.warning("Summaries file not found in Hugging Face dataset")
+ return {}
+ except Exception as e:
+ logger.error(f"Error loading summaries from Hugging Face dataset: {e}")
+ return {}
+
+@st.cache_data
+def load_bill_suggested_questions() -> dict:
+ """Load pre-generated suggested questions from Hugging Face dataset."""
+ try:
+ questions = load_from_hf_dataset("bill_suggested_questions.json")
+ if questions is not None:
+ logger.info(f"Loaded {len(questions)} pre-generated question sets from Hugging Face dataset")
+ return questions
+ else:
+ logger.warning("Questions file not found in Hugging Face dataset")
+ return {}
+ except Exception as e:
+ logger.error(f"Error loading questions from Hugging Face dataset: {e}")
+ return {}
+
+def get_bill_suggested_questions(bill_data, questions_cache):
+ """Get suggested questions for a bill from cache or return fallback."""
+ bill_key = f"{bill_data.get('state', 'Unknown')}_{bill_data.get('bill_number', 'Unknown')}"
+
+ if bill_key in questions_cache:
+ questions = questions_cache[bill_key].get('suggested_questions', [])
+ if len(questions) == 5:
+ return questions
+
+ # Fallback to static example questions
+ return [
+ "What are the key definitions in this bill?",
+ "What are the enforcement mechanisms?",
+ "Who does this bill apply to?",
+ "What are the compliance requirements?",
+ "What penalties are specified?"
+ ]
+
+def get_bill_summary(bill_data, summaries_cache):
+ """Get summary for a bill from cache or return error message."""
+ bill_key = f"{bill_data.get('state', 'Unknown')}_{bill_data.get('bill_number', 'Unknown')}"
+
+ if bill_key in summaries_cache:
+ summary = summaries_cache[bill_key].get('summary', '')
+ if summary.startswith('ERROR:'):
+ return f"Summary generation failed: {summary}"
+ return summary
+ else:
+ return "No pre-generated summary available. Run the summary generation script first."
+
+@st.cache_data
+def load_and_process_data() -> pd.DataFrame:
+ start_time = time.time()
+
+ try:
+ bills_data = load_from_hf_dataset("known_bills_visualize.json")
+ if bills_data is None:
+ logger.warning("Data file not found in Hugging Face dataset")
+ return None
+
+ logger.info(f"Loaded {len(bills_data)} bills from Hugging Face dataset")
+
+ df = pd.DataFrame(bills_data)
+ # Convert dates
+ if "last_action_date" in df.columns:
+ df["last_action_date"] = pd.to_datetime(
+ df["last_action_date"], errors="coerce"
+ )
+ if "lastUpdatedAt" in df.columns:
+ df["lastUpdatedAt"] = pd.to_datetime(df["lastUpdatedAt"], errors="coerce")
+
+ logger.info(f"DataFrame created in {time.time() - start_time:.2f} seconds")
+ return df
+ except Exception as e:
+ logger.error(f"Error loading data from Hugging Face dataset: {e}")
+ return None
+
+# Load OpenAI API key from Hugging Face secrets, Streamlit secrets, or environment variable
+def load_openai_api_key():
+ # First, try Hugging Face secrets (for Hugging Face Spaces deployment)
+ try:
+ # In Hugging Face Spaces, secrets are available as environment variables
+ # with the prefix HF_SECRETS_
+ hf_api_key = os.environ.get("HF_SECRETS_OPENAI_API_KEY")
+ if hf_api_key:
+ logger.info("Loaded OpenAI API key from Hugging Face secrets.")
+ return hf_api_key
+ except Exception as e:
+ logger.debug(f"Could not load from Hugging Face secrets: {e}")
+
+ # Second, try Streamlit secrets (for other deployed environments)
+ try:
+ return st.secrets["OPENAI_API_KEY"]
+ except (KeyError, FileNotFoundError):
+ logger.debug("Could not load from Streamlit secrets")
+
+ # Third, try environment variable (for local dev)
+ api_key = os.environ.get("OPENAI_API_KEY")
+ if api_key:
+ logger.info("Loaded OpenAI API key from environment variable.")
+ return api_key
+
+ # Finally, fallback to user input (for local dev without env var)
+ st.warning("OpenAI API key not found in Hugging Face secrets, Streamlit secrets, or environment variables.")
+ api_key = st.text_input("Enter your OpenAI API key:", type="password")
+ if api_key:
+ logger.info("OpenAI API key provided via user input.")
+ return api_key
+ else:
+ st.error("Please provide an OpenAI API key to continue.")
+ st.stop()
+
+# Load the key
+openai_api_key = load_openai_api_key()
+
+def display_bill_details(bill_data, summaries_cache):
+ """Display bill details and summary in a formatted way."""
+ st.markdown("#### Bill Details")
+
+ # Create columns for better layout
+ col1, col2 = st.columns(2)
+
+ with col1:
+ st.write(f"**State:** {bill_data.get('state', 'N/A')}")
+ st.write(f"**Bill Number:** {bill_data.get('bill_number', 'N/A')}")
+ st.write(f"**Status:** {bill_data.get('status', 'N/A')}")
+
+ # Format last action date
+ if 'last_action_date' in bill_data and pd.notna(bill_data['last_action_date']):
+ if isinstance(bill_data['last_action_date'], str):
+ st.write(f"**Last Action Date:** {bill_data['last_action_date']}")
+ else:
+ st.write(f"**Last Action Date:** {bill_data['last_action_date'].strftime('%Y-%m-%d')}")
+ else:
+ st.write(f"**Last Action Date:** N/A")
+
+ with col2:
+ # Extract IAPP categories for display
+ if 'iapp_categories' in bill_data and isinstance(bill_data['iapp_categories'], dict):
+ all_subcategories = []
+ for category, subcategories in bill_data['iapp_categories'].items():
+ if isinstance(subcategories, list):
+ all_subcategories.extend(subcategories)
+
+ if all_subcategories:
+ iapp_display = ", ".join(all_subcategories[:3]) # Show first 3
+ if len(all_subcategories) > 3:
+ iapp_display += f" + {len(all_subcategories) - 3} more"
+ else:
+ iapp_display = "None"
+ else:
+ iapp_display = "N/A"
+
+ st.write(f"**Categories:** {iapp_display}")
+
+ # Show sponsors if available
+ sponsors = bill_data.get('sponsors', 'N/A')
+ if isinstance(sponsors, list):
+ sponsors_display = ", ".join(sponsors[:2]) # Show first 2 sponsors
+ if len(sponsors) > 2:
+ sponsors_display += f" + {len(sponsors) - 2} more"
+ else:
+ sponsors_display = str(sponsors) if sponsors else "N/A"
+
+ st.write(f"**Sponsors:** {sponsors_display}")
+
+ # Show full title
+ st.write(f"**Title:** {bill_data.get('title', 'N/A')}")
+
+ # Display pre-generated summary
+ st.markdown("#### Bill Summary")
+ summary = get_bill_summary(bill_data, summaries_cache)
+
+ if summary.startswith('Summary generation failed') or summary.startswith('No pre-generated summary'):
+ st.warning(summary)
+ else:
+ st.info(summary)
+
+df = load_and_process_data()
+summaries_cache = load_bill_summaries()
+questions_cache = load_bill_suggested_questions()
+
+if df is None:
+ st.write("No data available. Ensure the VAILL/legislation-tracker-data Hugging Face dataset contains the required files.")
+ st.stop()
+
+# Sidebar for filters with improved styling
+with st.sidebar:
+ # Add logo to the sidebar
+ logo_path = "vaill_logo.png"
+ try:
+ st.image(logo_path, use_container_width=True)
+ except FileNotFoundError:
+ st.warning("Logo image 'vaill_logo.png' not found.")
+
+ st.markdown("### Filter Controls")
+
+ # Date Filter Section
+ date_df = (
+ df.dropna(subset=["last_action_date"])
+ if "last_action_date" in df.columns
+ else pd.DataFrame()
+ )
+
+ if not date_df.empty:
+ current_date = datetime.now().date()
+ df_dates = df[df["last_action_date"].notna()]["last_action_date"]
+ min_year = df_dates.min().year
+ max_year = min(df_dates.max().year, current_date.year)
+
+ st.markdown("#### Date Range")
+
+ filter_type = st.radio(
+ "Filter by:",
+ options=["No Date Filter", "Year Only", "Year & Month"],
+ index=0,
+ help="Choose how to filter bills by their last action date"
+ )
+
+ # Initialize filtered_df
+ filtered_df = df.copy()
+
+ if filter_type == "Year Only":
+ available_years = sorted(df_dates.dt.year.unique())
+ available_years = [year for year in available_years if year <= current_date.year]
+
+ if available_years:
+ selected_years = st.multiselect(
+ "Select Years:",
+ options=available_years,
+ default=[max(available_years)],
+ help="Select one or more years to filter bills"
+ )
+
+ if selected_years:
+ mask = df["last_action_date"].dt.year.isin(selected_years)
+ filtered_df = df[mask].copy()
+
+ if len(selected_years) == 1:
+ year_range = f"{selected_years[0]}"
+ else:
+ year_range = f"{min(selected_years)}-{max(selected_years)}"
+ st.success(f"Filtering: {year_range}")
+ else:
+ st.warning("No years available for filtering")
+
+ elif filter_type == "Year & Month":
+ available_years = list(range(min_year, max_year + 1))
+
+ col1, col2 = st.columns(2)
+
+ with col1:
+ start_year = st.selectbox(
+ "From Year:",
+ options=available_years,
+ index=max(0, len(available_years) - 2) if len(available_years) > 1 else 0,
+ key="start_year_select"
+ )
+
+ with col2:
+ end_year_options = list(range(start_year, max_year + 1))
+ end_year = st.selectbox(
+ "To Year:",
+ options=end_year_options,
+ index=len(end_year_options) - 1,
+ key="end_year_select"
+ )
+
+ months = {
+ 1: "January", 2: "February", 3: "March", 4: "April",
+ 5: "May", 6: "June", 7: "July", 8: "August",
+ 9: "September", 10: "October", 11: "November", 12: "December"
+ }
+
+ col3, col4 = st.columns(2)
+
+ with col3:
+ start_month = st.selectbox(
+ "From Month:",
+ options=list(months.keys()),
+ format_func=lambda x: months[x],
+ index=0,
+ key="start_month_select"
+ )
+
+ with col4:
+ max_end_month = 12
+ if end_year == current_date.year:
+ max_end_month = current_date.month
+
+ end_month_options = list(range(1, max_end_month + 1))
+ if end_month_options:
+ end_month = st.selectbox(
+ "To Month:",
+ options=end_month_options,
+ format_func=lambda x: months[x],
+ index=len(end_month_options) - 1,
+ key="end_month_select"
+ )
+ else:
+ end_month = 1
+ st.warning("Invalid month range")
+
+ try:
+ start_date = date(start_year, start_month, 1)
+
+ if end_month == 12:
+ last_day = date(end_year + 1, 1, 1) - timedelta(days=1)
+ else:
+ last_day = date(end_year, end_month + 1, 1) - timedelta(days=1)
+
+ end_date = min(last_day, current_date)
+
+ if start_date <= end_date:
+ mask = (df["last_action_date"].dt.date >= start_date) & (
+ df["last_action_date"].dt.date <= end_date
+ )
+ filtered_df = df[mask].copy()
+
+ st.success(f"Filtering: {start_date.strftime('%b %Y')} - {end_date.strftime('%b %Y')}")
+ else:
+ st.error("Start date must be before end date")
+
+ except ValueError as e:
+ st.error(f"Invalid date range: {e}")
+
+ if filter_type != "No Date Filter" and not filtered_df.empty:
+ date_stats = filtered_df["last_action_date"].dropna()
+ if not date_stats.empty:
+ st.info(f"{len(date_stats)} bills with dates in range")
+
+ else:
+ filtered_df = df.copy()
+ st.warning("No date information available for filtering")
+
+ st.markdown("#### Bill Type")
+ bill_type_filter = st.radio(
+ "Show bills:",
+ options=["All Bills", "State Bills Only", "Federal Bills Only"],
+ index=0
+ )
+
+ # Apply the filter
+ if bill_type_filter == "State Bills Only":
+ filtered_df = filtered_df[filtered_df["state"] != "US"]
+ elif bill_type_filter == "Federal Bills Only":
+ filtered_df = filtered_df[filtered_df["state"] == "US"]
+
+ # IAPP Categories Filter
+ if "iapp_categories" in filtered_df.columns:
+ st.markdown("#### Categories")
+
+ all_iapp_categories = set()
+ filtered_df["iapp_categories"].apply(
+ lambda x: all_iapp_categories.update(x.keys()) if isinstance(x, dict) else None
+ )
+
+ if all_iapp_categories:
+ for category in sorted(all_iapp_categories):
+ if category in IAPP_CATEGORIES:
+ all_subcategories = set()
+ filtered_df["iapp_categories"].apply(
+ lambda x: all_subcategories.update(x.get(category, []))
+ if isinstance(x, dict) and category in x else None
+ )
+
+ if all_subcategories:
+ subcategory_options = sorted(all_subcategories)
+ selected_subcategories = st.multiselect(
+ f"{category}",
+ options=subcategory_options,
+ default=[],
+ key=f"iapp_{category.lower().replace(' ', '_')}"
+ )
+
+ if selected_subcategories:
+ filtered_df = filtered_df[
+ filtered_df["iapp_categories"].apply(
+ lambda x: (
+ any(subcat in x.get(category, []) for subcat in selected_subcategories)
+ if isinstance(x, dict) and category in x
+ else False
+ )
+ )
+ ]
+
+# Main content with tab-based layout
+(tab1, tab2, tab3) = st.tabs([
+ TOOL_DESCRIPTIONS["bills_table"]["name"],
+ TOOL_DESCRIPTIONS["bills_map"]["name"],
+ TOOL_DESCRIPTIONS["ai_toolkit"]["name"]
+])
+
+# TAB 1: BILLS EXPLORER
+with tab1:
+ st.markdown(f' {TOOL_DESCRIPTIONS["bills_table"]["description"]} Current statistics for filtered bill dataset Federal Bills Bills This Year Last Updated State Bills Most Active State Most Active State Last Updated Federal Bills State Bills Most Active State Most Active State Last Updated Comprehensive listing of AI governance legislation The data in this tracker is compiled from state legislative records, primarily utilizing the Legiscan API. The tracker focuses on state-level legislation, with federal bills included for context but separated in counts and views. Bill statuses are simplified into "Signed Into Law", "Active", and "Inactive" for clarity. {TOOL_DESCRIPTIONS["bills_map"]["description"]} Interactive visualization of AI governance bills across US states {TOOL_DESCRIPTIONS["ai_toolkit"]["description"]} Choose your preferred AI-powered analysis method {}What is the AI Governance Legislation Tracker?
+ Database Overview
{}
{}
{}
{}
{}
N/A
{}
{}
{}
{}
N/A
{}
Legislation Database
How to Use This Tracker
+
+
+ About the Data
+ Geographic Distribution Map
" + "".join(_bill_label(r) for _, r in g.iterrows()) + "
"
+
+ if not tmp.empty:
+ # Try pandas >= 2.2 signature first (supports include_groups)
+ try:
+ bills_by_state = (
+ tmp.groupby('state', group_keys=False)
+ .apply(_group_to_ul, include_groups=False)
+ .to_dict()
+ )
+ except TypeError:
+ # Older pandas: no include_groups; still safe due to the 'state' check in _group_to_ul
+ bills_by_state = (
+ tmp.groupby('state', group_keys=False)
+ .apply(_group_to_ul)
+ .to_dict()
+ )
+ else:
+ bills_by_state = {}
+
+ # --- GeoJSON & properties (also build scrollable popup HTML) ---
+ us_states = load_us_states_geojson()
+
+ for feat in us_states.get('features', []):
+ abbr = (feat.get('id') or "").upper()
+ props = feat.setdefault('properties', {})
+ props['bills'] = int(state_to_count.get(abbr, 0))
+
+ state_name = html.escape(props.get('name', ''))
+ total = props['bills']
+ list_html = bills_by_state.get(abbr, "No bills for the selected session.")
+
+ count_label = "bill" if total == 1 else "bills"
+ props['popup_html'] = (
+ f"{state_name} — {total} {count_label}"
+ + (f" ({html.escape(selected_year)})" if selected_year else "")
+ + "
"
+ f"Analysis Type Selection
{}