""" 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)