| """ |
| 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 |
|
|
| |
| project_root = Path(__file__).parent.parent |
| sys.path.insert(0, str(project_root)) |
|
|
| logger = logging.getLogger("agadvisor.app") |
|
|
| |
| 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() |
|
|
| |
| |
| |
|
|
| st.set_page_config( |
| page_title="AgAdvisor", |
| page_icon="🌿", |
| layout="centered", |
| initial_sidebar_state="collapsed" |
| ) |
|
|
| |
| |
| |
|
|
| st.markdown(""" |
| <style> |
| /* Formal, nature-toned palette: muted sage / earth / slate / olive on light |
| backgrounds. Badges use light fills with dark text (color-coded but subtle) |
| rather than saturated blocks, for a professional look. */ |
| :root { |
| --forest: #2f6b4f; /* primary deep sage (titles, accents) */ |
| --sage: #4a7c59; |
| --sage-soft: #e4efe7; --sage-border: #bcd4c2; |
| --earth: #7a5f2e; |
| --earth-soft: #f2ead8; --earth-border: #e3d3ad; |
| --slate: #2f5b64; |
| --slate-soft: #e2eef0; --slate-border: #bcd8dd; |
| --olive: #566234; |
| --olive-soft: #eaeede; --olive-border: #d3dab9; |
| --muted: #5c6b60; |
| } |
| .main-title { |
| font-size: 2rem; |
| font-weight: 600; |
| text-align: center; |
| color: var(--forest); |
| letter-spacing: .2px; |
| margin-bottom: .4rem; |
| } |
| .subtitle { |
| text-align: center; |
| color: var(--muted); |
| font-size: .97rem; |
| margin-bottom: 1.6rem; |
| } |
| .user-message { |
| background-color: #eef4f0; |
| padding: 15px; |
| border-radius: 8px; |
| margin: 10px 0; |
| border-left: 3px solid var(--sage); |
| } |
| .assistant-message { |
| background-color: #f6f8f5; |
| padding: 15px; |
| border-radius: 8px; |
| margin: 10px 0; |
| border-left: 3px solid #9bb58a; |
| } |
| /* Light, color-coded metadata badges (formal, nature-toned) */ |
| .tool-badge, .keyword-badge, .confidence-badge, .citation-badge { |
| display: inline-block; |
| padding: 4px 10px; |
| border-radius: 6px; |
| font-size: 0.82rem; |
| font-weight: 600; |
| margin: 5px 5px 5px 0; |
| border: 1px solid transparent; |
| } |
| .tool-badge { background: var(--sage-soft); color: var(--forest); border-color: var(--sage-border); } |
| .keyword-badge { background: var(--earth-soft); color: var(--earth); border-color: var(--earth-border); } |
| .confidence-badge { background: var(--slate-soft); color: var(--slate); border-color: var(--slate-border); } |
| .citation-badge { background: var(--olive-soft); color: var(--olive); border-color: var(--olive-border); } |
| /* Chat input: subtle, formal (no bright gradient) */ |
| .stChatInput { |
| border: 1.5px solid #cdddcf !important; |
| border-radius: 8px !important; |
| background: #fbfdfb !important; |
| } |
| .stChatInput:focus-within { |
| border: 1.5px solid var(--sage) !important; |
| box-shadow: 0 0 0 3px rgba(74, 124, 89, 0.12) !important; |
| } |
| /* Buttons: light sage, formal */ |
| .stButton > button { |
| border: 1px solid var(--sage-border) !important; |
| background: #f3f8f4 !important; |
| color: var(--forest) !important; |
| font-weight: 600 !important; |
| border-radius: 8px !important; |
| } |
| .stButton > button:hover { |
| border-color: var(--sage) !important; |
| background: var(--sage-soft) !important; |
| } |
| /* Answer metadata badges: flex-wrap so they never overflow, readable on phone */ |
| .message-badges { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| align-items: center; |
| } |
| /* Mobile fix (ISA feedback: star rating clipped on phone). Enlarge and stack |
| the badges on narrow screens instead of letting them shrink and clip. */ |
| @media (max-width: 640px) { |
| .message-badges { |
| flex-direction: column; |
| align-items: flex-start; |
| gap: 8px; |
| } |
| .tool-badge, .keyword-badge, .confidence-badge, .citation-badge { |
| font-size: 0.92rem; |
| padding: 6px 12px; |
| margin: 0; |
| max-width: 100%; |
| } |
| .main-title { font-size: 1.35rem; } |
| .assistant-message, .user-message { padding: 12px; } |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| |
| |
| 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(): |
| |
| 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 [] |
|
|
|
|
| |
| 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 |
|
|
|
|
| |
| if st.session_state.get("chats_user_id") != user_id or "chats" not in st.session_state: |
| _hydrate_user_chats(accounts, user_id) |
|
|
| |
| st.session_state.tool_matcher = get_tool_matcher() |
| st.session_state.tool_executor = get_tool_executor() |
| st.session_state.label_products = get_label_products() |
|
|
| |
| st.session_state.conversation_history = st.session_state.chats.get( |
| st.session_state.current_chat_id, {} |
| ).get('messages', []) |
|
|
| |
| |
| |
|
|
| |
| col_title, col_new_chat = st.columns([4, 1]) |
|
|
| with col_title: |
| st.markdown('<div class="main-title">🌿 AgAdvisor</div>', unsafe_allow_html=True) |
|
|
| with col_new_chat: |
| if st.button("New chat", type="primary", use_container_width=True): |
| |
| 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( |
| '<div class="subtitle">A CDMS pesticide-label assistant with weather, soil, and agronomic tools. Answers include page-level citations.</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| |
| |
|
|
| |
| 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" |
| ) |
|
|
| |
| 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("---") |
|
|
| |
| 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") |
|
|
| |
| |
| |
|
|
| |
| messages = current_chat['messages'] |
|
|
| |
| chat_container = st.container() |
|
|
| with chat_container: |
| if not messages: |
| st.info("👋 Welcome! Start a conversation by typing a question below.") |
| else: |
| |
| for idx, message in enumerate(messages): |
| if message["role"] == "user": |
| st.markdown(f""" |
| <div class="user-message"> |
| <strong>👤 You:</strong><br> |
| {message["content"]} |
| </div> |
| """, unsafe_allow_html=True) |
| |
| else: |
| st.markdown(f""" |
| <div class="assistant-message"> |
| <strong>🤖 AgAdvisor:</strong><br> |
| {message["content"]} |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| metadata = message.get("metadata", {}) |
| if metadata: |
| badges_html = f""" |
| <div class="message-badges" style="margin-top: 10px;"> |
| <span class="tool-badge">🔧 {metadata.get('tool', 'Unknown')}</span> |
| <span class="confidence-badge">📊 {metadata.get('confidence', 0):.0%} confidence</span> |
| """ |
| |
| keywords = metadata.get('keywords', []) |
| if keywords: |
| keywords_text = ", ".join(keywords[:3]) |
| badges_html += f'<span class="keyword-badge">🔑 {keywords_text}</span>' |
| |
| |
| 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'<span class="citation-badge">{badge_text}</span>' |
| |
| badges_html += "</div>" |
| st.markdown(badges_html, unsafe_allow_html=True) |
|
|
| |
| |
| |
|
|
| |
| 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, |
| ) |
|
|
| |
| if 'example_input' in st.session_state: |
| user_input = st.session_state.example_input |
| del st.session_state.example_input |
|
|
| |
| |
| if user_input: |
| from src.utils.input_guard import sanitize_user_query |
| user_input = sanitize_user_query(user_input, MAX_QUERY_CHARS) |
|
|
| |
| current_chat = st.session_state.chats[st.session_state.current_chat_id] |
| has_new_input = user_input is not None and user_input.strip() != "" |
|
|
| |
| 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 |
|
|
| |
| |
| |
|
|
| if has_new_input or pending_processing_key: |
| |
| |
| if has_new_input: |
| |
| |
| if not accounts.check_quota(user_id): |
| st.warning( |
| "You've reached today's question limit. Please come back tomorrow." |
| ) |
| st.stop() |
|
|
| |
| |
| msg_count_before = len(current_chat['messages']) |
| processing_key = f"processing_{st.session_state.current_chat_id}_{msg_count_before}" |
|
|
| |
| 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 |
| |
| st.rerun() |
| else: |
| |
| processing_key = pending_processing_key |
| |
| |
| question_to_process = st.session_state.get(processing_key, user_input if has_new_input else "") |
| |
| |
| try: |
| with st.status("🤔 Processing your question...", expanded=True) as status: |
| |
| 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 = [] |
| |
| |
| st.write("**Step 2:** 🔄 Checking conversation context...") |
| conversation_context = [] |
| if len(current_chat['messages']) > 1: |
| recent_messages = current_chat['messages'][-6:-1] |
| 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") |
| |
| |
| 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) |
| |
| |
| 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)") |
| |
| |
| 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" |
| confidence = 0.3 |
| method = "fallback" |
| |
| |
| 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 |
| ) |
| |
| |
| 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: |
| |
| if tool_result.get("fallback_used"): |
| st.write(" ⚠️ CDMS found no results, using agriculture web search as fallback") |
| else: |
| st.write(" ✅ Tool executed successfully!") |
| |
| |
| 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]: |
| 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) |
| |
| |
| 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), |
| "original_tool": selected_tool, |
| "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) |
| } |
| }) |
|
|
| |
| 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), |
| }, |
| ) |
|
|
| |
| if processing_key in st.session_state: |
| del st.session_state[processing_key] |
|
|
| |
| st.rerun() |
| |
| except Exception as e: |
| |
| |
| logger.exception("Unexpected error while processing a query") |
| st.error("Something went wrong while processing your request. Please try again.") |
|
|
| |
| _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"}, |
| ) |
|
|
| |
| 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() |
|
|
| |
|
|
| |
| |
| |
|
|
| with st.sidebar: |
| |
| 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") |
|
|
| |
| 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("---") |
| |
| |
| sorted_chats = sorted( |
| st.session_state.chats.items(), |
| key=lambda x: x[1]['created_at'], |
| reverse=True |
| ) |
| |
| |
| for chat_id, chat_data in sorted_chats: |
| |
| msg_count = len(chat_data['messages']) |
| |
| |
| 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'] |
| |
| |
| 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 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("---") |
| |
| |
| 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']) |
| }) |
|
|
| |
| |
| |
|
|
| st.markdown("---") |
| st.markdown(""" |
| <div style="text-align: center; color: #666; font-size: 0.9rem;"> |
| Powered by LangGraph, spaCy, OpenAI, Qdrant, and Tavily | |
| CDMS Labels • USDA Soil Data • Real-time Weather • Web Search with Citations |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|