"""
Conversational AI Assistant - Streamlit UI
Natural language input β Tool selection β LLM response β User
"""
import streamlit as st
import sys
import time
import logging
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
logger = logging.getLogger("agadvisor.app")
# Cap on characters accepted per chat message β bounds LLM cost/DoS on a public Space.
MAX_QUERY_CHARS = 2000
from src.parser import parse_query
from src.tools.tool_matcher import ToolMatcher
from src.tools.tool_executor import ToolExecutor
def _pretty_product(name: str) -> str:
"""Render a normalized catalog product name for display.
'roundup' -> 'Roundup', '24-d' -> '2,4-D', 'boron' -> 'Boron'.
"""
special = {"24-d": "2,4-D"}
if name in special:
return special[name]
return name.upper() if len(name) <= 4 else name.title()
# ============================================================================
# PAGE CONFIGURATION
# ============================================================================
st.set_page_config(
page_title="AgAdvisor",
page_icon="πΏ",
layout="centered",
initial_sidebar_state="collapsed"
)
# ============================================================================
# CUSTOM STYLING
# ============================================================================
st.markdown("""
""", unsafe_allow_html=True)
# ============================================================================
# ACCOUNTS + SHARED RESOURCES (auth gate must precede chat state)
# ============================================================================
from src.accounts.service import AccountsService
from src.accounts import ui as accounts_ui
@st.cache_resource(show_spinner=False)
def get_accounts_service():
"""One AccountsService per process. On construction it pulls the durable DB
from the private HF Dataset, so a restarted Space resumes with all accounts."""
return AccountsService()
@st.cache_resource(show_spinner="Loading modelsβ¦")
def get_tool_matcher():
# Shared across all sessions on the Space (was per-session β big cold-start win).
return ToolMatcher()
@st.cache_resource(show_spinner="Loading toolsβ¦")
def get_tool_executor():
return ToolExecutor()
@st.cache_data(show_spinner=False)
def get_label_products():
try:
from src.cdms.product_catalog import get_catalog
return sorted(get_catalog().available_products())
except Exception:
return []
# --- Authentication gate: anonymous users see the login screen and stop here ---
accounts = get_accounts_service()
user = accounts_ui.require_auth(accounts)
user_id = user["id"]
def _hydrate_user_chats(svc, uid):
"""Load this user's chats + messages from the durable store into session_state.
session_state stays the working UI model; every mutation is mirrored to the DB."""
chats_meta = svc.list_chats(uid)
if not chats_meta:
svc.create_chat(uid, "Chat 1")
chats_meta = svc.list_chats(uid)
chats = {}
for c in chats_meta:
chats[c["id"]] = {
"name": c["name"],
"messages": svc.get_messages(c["id"], uid),
"created_at": c["created_at"],
}
st.session_state.chats = chats
st.session_state.current_chat_id = chats_meta[0]["id"]
st.session_state.chat_counter = len(chats_meta)
st.session_state.chats_user_id = uid
# (Re)hydrate on first load this session or when a different user signs in.
if st.session_state.get("chats_user_id") != user_id or "chats" not in st.session_state:
_hydrate_user_chats(accounts, user_id)
# Shared singletons + catalog (cached; assigned each run for the code below).
st.session_state.tool_matcher = get_tool_matcher()
st.session_state.tool_executor = get_tool_executor()
st.session_state.label_products = get_label_products()
# For backwards compatibility
st.session_state.conversation_history = st.session_state.chats.get(
st.session_state.current_chat_id, {}
).get('messages', [])
# ============================================================================
# HEADER WITH CHAT CONTROLS
# ============================================================================
# Chat management in header
col_title, col_new_chat = st.columns([4, 1])
with col_title:
st.markdown('
πΏ AgAdvisor
', unsafe_allow_html=True)
with col_new_chat:
if st.button("New chat", type="primary", use_container_width=True):
# Create the chat in the durable store first so we use its real id.
st.session_state.chat_counter += 1
new_name = f'Chat {st.session_state.chat_counter}'
new_chat_id = accounts.create_chat(user_id, new_name)
st.session_state.chats[new_chat_id] = {
'name': new_name,
'messages': [],
'created_at': time.time()
}
st.session_state.current_chat_id = new_chat_id
st.rerun()
st.markdown(
'A CDMS pesticide-label assistant with weather, soil, and agronomic tools. Answers include page-level citations.
',
unsafe_allow_html=True
)
# ============================================================================
# EXAMPLE QUESTIONS (MOVED TO TOP)
# ============================================================================
# Example queries section at the top
with st.expander("πΏ Example questions", expanded=False):
label_products = st.session_state.get('label_products', [])
st.markdown("**π§ Tools**")
col1, col2, col3 = st.columns(3)
with col1:
if st.button("π‘οΈ Weather", use_container_width=True, key="ex_weather"):
st.session_state.example_input = "What's the weather in London?"
with col2:
if st.button("πΊοΈ Soil data", use_container_width=True, key="ex_soil"):
st.session_state.example_input = "Show me soil data for Iowa"
with col3:
if st.button("π·οΈ Pesticide labels", use_container_width=True, key="ex_cdms"):
first = _pretty_product(label_products[0]) if label_products else None
st.session_state.example_input = (
f"Find the {first} label" if first else "Find a pesticide label"
)
# CDMS pesticide labels β generated from the products actually in the index.
st.markdown("**π Pesticide labels (CDMS)**")
if label_products:
shown = label_products[:6]
for row_start in range(0, len(shown), 3):
cols = st.columns(3)
for i, product in enumerate(shown[row_start:row_start + 3]):
with cols[i]:
disp = _pretty_product(product)
if st.button(f"π§ͺ {disp}", use_container_width=True, key=f"ex_label_{product}"):
st.session_state.example_input = f"Show me the {disp} label"
st.caption(f"{len(label_products)} label(s) available in the current index.")
else:
st.caption("No labels are indexed yet β run the offline build to populate the catalog.")
st.markdown("**π Agriculture information**")
col7, col8, col9 = st.columns(3)
with col7:
if st.button("π‘οΈ Pest control", use_container_width=True, key="ex_pest"):
st.session_state.example_input = "How to control aphids on tomato plants?"
with col8:
if st.button("π± Fertilization", use_container_width=True, key="ex_fert"):
st.session_state.example_input = "Best practices for corn fertilization timing"
with col9:
if st.button("π Soil health", use_container_width=True, key="ex_soil_health"):
st.session_state.example_input = "How to improve soil organic matter?"
st.markdown("---")
# Show current chat info
current_chat = st.session_state.chats[st.session_state.current_chat_id]
msg_count = len(current_chat['messages'])
st.caption(f"{current_chat['name']} β’ {msg_count} messages")
# ============================================================================
# DISPLAY CONVERSATION HISTORY (CHAT-STYLE)
# ============================================================================
# Get messages for current chat
messages = current_chat['messages']
# Create a container for messages (chat window)
chat_container = st.container()
with chat_container:
if not messages:
st.info("π Welcome! Start a conversation by typing a question below.")
else:
# Display messages in chronological order (oldest to newest, like ChatGPT)
for idx, message in enumerate(messages):
if message["role"] == "user":
st.markdown(f"""
π€ You:
{message["content"]}
""", unsafe_allow_html=True)
else: # assistant
st.markdown(f"""
π€ AgAdvisor:
{message["content"]}
""", unsafe_allow_html=True)
# Show metadata badges
metadata = message.get("metadata", {})
if metadata:
badges_html = f"""
π§ {metadata.get('tool', 'Unknown')}
π {metadata.get('confidence', 0):.0%} confidence
"""
keywords = metadata.get('keywords', [])
if keywords:
keywords_text = ", ".join(keywords[:3])
badges_html += f'π {keywords_text}'
# Check for citations
raw_data = metadata.get('raw_data', {})
if raw_data and 'citations' in raw_data and raw_data.get('citations'):
badge_text = "π Citations Included"
if 'labels' in raw_data:
count = len(raw_data.get('labels', []))
badge_text = f"π {count} Source(s)"
elif 'sources' in raw_data:
count = len(raw_data.get('sources', []))
badge_text = f"π {count} Source(s)"
badges_html += f'{badge_text}'
badges_html += "
"
st.markdown(badges_html, unsafe_allow_html=True)
# ============================================================================
# INPUT SECTION (AT BOTTOM, LIKE CHATGPT)
# ============================================================================
# Chat input (like ChatGPT)
user_input = st.chat_input(
placeholder="Type your message here... e.g., 'Find Roundup label', 'Weather in Paris?', 'How to control aphids?'",
key="chat_input",
max_chars=MAX_QUERY_CHARS, # bound per-message LLM cost / DoS on a public Space
)
# Handle example button clicks
if 'example_input' in st.session_state:
user_input = st.session_state.example_input
del st.session_state.example_input
# Defense in depth: normalize + hard-cap anything reaching the LLM (covers example
# injection and clients that bypass the widget's max_chars).
if user_input:
from src.utils.input_guard import sanitize_user_query
user_input = sanitize_user_query(user_input, MAX_QUERY_CHARS)
# Process if there's input OR if there's a pending processing task
current_chat = st.session_state.chats[st.session_state.current_chat_id]
has_new_input = user_input is not None and user_input.strip() != ""
# Check for pending processing (after rerun)
pending_processing_key = None
for key in st.session_state.keys():
if key.startswith(f"processing_{st.session_state.current_chat_id}_"):
pending_processing_key = key
break
# ============================================================================
# PROCESS QUERY
# ============================================================================
if has_new_input or pending_processing_key:
# Get current chat (already have it)
if has_new_input:
# Per-user daily quota: only signed-in users spend the shared OpenAI key,
# and each is capped. Block (don't record) once the cap is reached.
if not accounts.check_quota(user_id):
st.warning(
"You've reached today's question limit. Please come back tomorrow."
)
st.stop()
# New input - add user message and set processing flag
# Use message count before adding to create unique key
msg_count_before = len(current_chat['messages'])
processing_key = f"processing_{st.session_state.current_chat_id}_{msg_count_before}"
# Add user message to current chat (session) and persist to the store.
current_chat['messages'].append({
"role": "user",
"content": user_input,
"timestamp": time.time()
})
accounts.add_message(st.session_state.current_chat_id, user_id, "user", user_input)
accounts.record_query(user_id)
st.session_state[processing_key] = user_input
# Rerun immediately to show user message
st.rerun()
else:
# Pending processing - continue with existing processing key
processing_key = pending_processing_key
# Get the question to process (from session state)
question_to_process = st.session_state.get(processing_key, user_input if has_new_input else "")
# Processing with detailed status (like before)
try:
with st.status("π€ Processing your question...", expanded=True) as status:
# Step 1: Parse and extract keywords
st.write("**Step 1:** π Analyzing your question...")
try:
parsed = parse_query(question_to_process)
keywords = parsed.get("extracted_keywords", [])
st.write(f" β
Keywords: {', '.join(keywords[:5])}")
except Exception:
st.write(" β οΈ Using direct matching")
keywords = []
# Step 2: Get conversation history for context (needed for tool matching)
st.write("**Step 2:** π Checking conversation context...")
conversation_context = []
if len(current_chat['messages']) > 1: # Has previous messages
recent_messages = current_chat['messages'][-6:-1] # Last 5 before current
for msg in recent_messages:
conversation_context.append({
"role": msg["role"],
"content": msg["content"]
})
st.write(f" β
Using context from {len(conversation_context)} previous messages")
else:
st.write(" βΉοΈ No previous context")
# Step 3: Match with tools (with context)
st.write("**Step 3:** π― Selecting best tool...")
try:
tool_match = st.session_state.tool_matcher.match_tool(
keywords,
question_to_process,
conversation_context=conversation_context
)
selected_tool = tool_match["tool_name"]
confidence = tool_match["confidence"]
method = tool_match.get("method", "unknown")
llm_used = tool_match.get("llm_used", False)
# Display method used
if method == "fast_path":
st.write(" β‘ Fast path (keyword matching)")
elif method == "llm_path" or method == "llm_cached":
st.write(f" π§ LLM classification ({'cached' if method == 'llm_cached' else 'live'})")
elif method == "hybrid":
st.write(" π Hybrid (fast + LLM)")
else:
st.write(f" βοΈ {method}")
st.write(f" β
Selected: **{selected_tool}** ({confidence:.0%} confidence)")
# Show LLM reasoning if available
if llm_used and tool_match.get("llm_reasoning"):
st.write(f" π Reasoning: {tool_match['llm_reasoning'][:100]}...")
except Exception:
st.write(" β οΈ Using default tool")
selected_tool = "cdms_label" # Default fallback (CDMS is now the RAG tool)
confidence = 0.3
method = "fallback"
# Step 4: Execute tool (with conversation context)
st.write(f"**Step 4:** βοΈ Executing **{selected_tool}** tool...")
try:
tool_result = st.session_state.tool_executor.execute(
tool_name=selected_tool,
user_question=question_to_process,
conversation_context=conversation_context # Pass context for follow-ups
)
# Check if execution was successful
if not tool_result.get("success", False):
error_msg = tool_result.get("error", "Unknown error")
tool_result["llm_response"] = f"I encountered an error: {error_msg}"
st.write(f" β Error: {error_msg}")
else:
# Check if fallback was used
if tool_result.get("fallback_used"):
st.write(" β οΈ CDMS found no results, using agriculture web search as fallback")
else:
st.write(" β
Tool executed successfully!")
# Show PDF download info for CDMS tool
if selected_tool in ["cdms_label", "cdms", "pesticide_label"]:
raw_data = tool_result.get("raw_data", {})
pdfs_downloaded = raw_data.get("pdfs_downloaded", 0)
pdfs_indexed = raw_data.get("pdfs_indexed", 0)
if pdfs_downloaded > 0:
st.write(f" π₯ Downloaded {pdfs_downloaded} PDF(s) from CDMS")
if pdfs_indexed > 0:
st.write(f" π Indexed {pdfs_indexed} PDF(s) for RAG search")
download_info = raw_data.get("download_info", {})
downloaded_pdfs = download_info.get("downloaded_pdfs", [])
if downloaded_pdfs:
st.write(" π PDFs:")
for pdf in downloaded_pdfs[:3]: # Show first 3
cached = "cached" if pdf.get("cached") else "new"
st.write(f" - {pdf.get('filename', 'Unknown')} ({cached})")
except Exception as e:
logger.exception("Tool execution error")
tool_result = {
"success": False,
"error": "tool_execution_error",
"llm_response": "I couldn't complete that request due to an internal error. Please try again."
}
st.write(" β Execution error (details logged server-side)")
status.update(label="β
Complete!", state="complete", expanded=False)
# Add assistant response to current chat
response_text = tool_result.get("llm_response", "I couldn't process that request. Please try again.")
current_chat['messages'].append({
"role": "assistant",
"content": response_text,
"timestamp": time.time(),
"metadata": {
"tool": tool_result.get("tool_used", selected_tool), # Use actual tool used (may be fallback)
"original_tool": selected_tool, # Keep original selection
"fallback_used": tool_result.get("fallback_used", False),
"keywords": keywords,
"confidence": confidence,
"raw_data": tool_result.get("raw_data"),
"success": tool_result.get("success", False),
"error": tool_result.get("error") if not tool_result.get("success") else None,
"has_context": len(conversation_context) > 0,
"context_messages": len(conversation_context)
}
})
# Persist the assistant turn (compact metadata only β no bulky raw_data).
accounts.add_message(
st.session_state.current_chat_id, user_id, "assistant", response_text,
metadata={
"tool": tool_result.get("tool_used", selected_tool),
"confidence": confidence,
"keywords": keywords[:5] if keywords else [],
"success": tool_result.get("success", False),
},
)
# Clear processing flag
if processing_key in st.session_state:
del st.session_state[processing_key]
# Rerun to show the new message
st.rerun()
except Exception as e:
# SECURITY: never render tracebacks/exception text to end users. Log full
# detail server-side; show a generic, friendly message in the UI.
logger.exception("Unexpected error while processing a query")
st.error("Something went wrong while processing your request. Please try again.")
# Add error message to current chat
_err_text = "I ran into an unexpected problem answering that. Please try rephrasing or ask again."
current_chat['messages'].append({
"role": "assistant",
"content": _err_text,
"timestamp": time.time(),
"metadata": {
"tool": "unknown",
"error": "internal_error"
}
})
accounts.add_message(
st.session_state.current_chat_id, user_id, "assistant", _err_text,
metadata={"tool": "unknown", "error": "internal_error"},
)
# Clear processing flag (use the one from outer scope)
if 'processing_key' in locals() and processing_key in st.session_state:
del st.session_state[processing_key]
elif pending_processing_key and pending_processing_key in st.session_state:
del st.session_state[pending_processing_key]
st.rerun()
# Clear chat button moved to sidebar
# ============================================================================
# SIDEBAR - CHAT MANAGEMENT
# ============================================================================
with st.sidebar:
# Signed-in user + logout + remaining daily quota.
st.markdown(f"### π€ {user['username']}")
st.caption(f"{accounts.remaining_quota(user_id)} questions left today")
if st.button("Log out", type="secondary", use_container_width=True, key="logout_btn"):
accounts_ui.logout(accounts)
st.markdown("---")
st.markdown("### Chat sessions")
# Clear current chat button
if st.button("Clear current chat", type="secondary", use_container_width=True, key="clear_sidebar"):
current_chat['messages'] = []
accounts.clear_messages(st.session_state.current_chat_id, user_id)
st.rerun()
st.markdown("---")
# Sort chats by created_at (newest first)
sorted_chats = sorted(
st.session_state.chats.items(),
key=lambda x: x[1]['created_at'],
reverse=True
)
# Display all chats
for chat_id, chat_data in sorted_chats:
# Count messages
msg_count = len(chat_data['messages'])
# Get first user message as preview
preview = "Empty chat"
if chat_data['messages']:
first_msg = next(
(msg for msg in chat_data['messages'] if msg['role'] == 'user'),
None
)
if first_msg:
preview = first_msg['content'][:30] + "..." if len(first_msg['content']) > 30 else first_msg['content']
# Create button for each chat
is_current = chat_id == st.session_state.current_chat_id
button_type = "primary" if is_current else "secondary"
col1, col2 = st.columns([4, 1])
with col1:
if st.button(
f"{'β' if is_current else 'β'} {chat_data['name']}\n{preview}\n({msg_count} msgs)",
key=f"chat_{chat_id}",
type=button_type,
use_container_width=True
):
if not is_current:
st.session_state.current_chat_id = chat_id
st.rerun()
with col2:
if not is_current and len(st.session_state.chats) > 1:
if st.button("β", key=f"delete_{chat_id}", help="Delete chat"):
accounts.delete_chat(chat_id, user_id)
del st.session_state.chats[chat_id]
# If we deleted the current chat, switch to another one
if st.session_state.current_chat_id == chat_id:
st.session_state.current_chat_id = list(st.session_state.chats.keys())[0]
st.rerun()
st.markdown("---")
# Debug mode
st.markdown("### Debug")
debug_mode = st.checkbox("Show debug info", value=False)
if debug_mode:
st.markdown("---")
st.markdown("### Session State")
current_chat = st.session_state.chats[st.session_state.current_chat_id]
st.json({
"total_chats": len(st.session_state.chats),
"current_chat_id": st.session_state.current_chat_id,
"current_chat_messages": len(current_chat['messages'])
})
# ============================================================================
# FOOTER
# ============================================================================
st.markdown("---")
st.markdown("""
Powered by LangGraph, spaCy, OpenAI, Qdrant, and Tavily |
CDMS Labels β’ USDA Soil Data β’ Real-time Weather β’ Web Search with Citations
""", unsafe_allow_html=True)