app.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import streamlit as st
  2. import pymysql
  3. import myloginpath
  4. import ollama
  5. import bcrypt
  6. import requests
  7. import json
  8. def local_web_search(query: str) -> str:
  9. """Search the internet anonymously for nutritional information not found in the database. Returns markdown."""
  10. try:
  11. req = requests.get(f'http://127.0.0.1:8080/search', params={'q': query, 'format': 'json'})
  12. if req.status_code == 200:
  13. data = req.json()
  14. results = data.get('results', [])
  15. if not results:
  16. return f"No results found on the web for '{query}'."
  17. # Extract top 3 results
  18. snippets = [f"Source: {r.get('url')}\nContent: {r.get('content')}" for r in results[:3]]
  19. return "\n\n".join(snippets)
  20. return "Search engine returned an error."
  21. except Exception as e:
  22. return f"Local search engine unreachable: {e}"
  23. search_tool_schema = {
  24. 'type': 'function',
  25. 'function': {
  26. 'name': 'local_web_search',
  27. 'description': 'Search the internet anonymously for nutritional information or recent food facts not found in the database.',
  28. 'parameters': {
  29. 'type': 'object',
  30. 'properties': {
  31. 'query': {
  32. 'type': 'string',
  33. 'description': 'The detailed search query to send to the external search engine.',
  34. },
  35. },
  36. 'required': ['query'],
  37. },
  38. },
  39. }
  40. # -------------------------------------------------------------------
  41. # Database Connections (PoLP & SoD)
  42. # -------------------------------------------------------------------
  43. def get_db_connection(login_path):
  44. """Dynamically connect using myloginpath to preserve Segregation of Duties."""
  45. try:
  46. conf = myloginpath.parse(login_path)
  47. return pymysql.connect(
  48. host=conf.get('host', '127.0.0.1'),
  49. user=conf.get('user'),
  50. password=conf.get('password'),
  51. database='food_db',
  52. cursorclass=pymysql.cursors.DictCursor
  53. )
  54. except Exception as e:
  55. st.error(f"Failed to connect using login-path '{login_path}'. Did you run mysql_config_editor?")
  56. st.sidebar.error(f"Connection Error: {e}")
  57. return None
  58. # -------------------------------------------------------------------
  59. # Authentication Logic
  60. # -------------------------------------------------------------------
  61. def verify_login(username, password):
  62. conn = get_db_connection('app_auth')
  63. if not conn: return False
  64. with conn.cursor() as cursor:
  65. cursor.execute("SELECT password_hash FROM users WHERE username = %s LIMIT 1", (username,))
  66. result = cursor.fetchone()
  67. conn.close()
  68. if result:
  69. # Check the hash
  70. return bcrypt.checkpw(password.encode('utf-8'), result['password_hash'].encode('utf-8'))
  71. return False
  72. def register_user(username, password):
  73. conn = get_db_connection('app_auth')
  74. if not conn: return False
  75. hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  76. try:
  77. with conn.cursor() as cursor:
  78. cursor.execute("INSERT INTO users (username, password_hash) VALUES (%s, %s)", (username, hashed))
  79. conn.commit()
  80. conn.close()
  81. return True
  82. except pymysql.err.IntegrityError:
  83. return False # Username exists
  84. # -------------------------------------------------------------------
  85. # UI Flow
  86. # -------------------------------------------------------------------
  87. st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
  88. if "authenticated_user" not in st.session_state:
  89. st.session_state["authenticated_user"] = None
  90. # Sidebar Authentication
  91. with st.sidebar:
  92. st.title("User Portal 🔐")
  93. if st.session_state["authenticated_user"]:
  94. st.success(f"Logged in as: {st.session_state['authenticated_user']}")
  95. if st.button("Logout"):
  96. st.session_state["authenticated_user"] = None
  97. st.rerun()
  98. else:
  99. tab1, tab2 = st.tabs(["Login", "Register"])
  100. with tab1:
  101. l_user = st.text_input("Username", key="l_user")
  102. l_pass = st.text_input("Password", type="password", key="l_pass")
  103. if st.button("Login"):
  104. if verify_login(l_user, l_pass):
  105. st.session_state["authenticated_user"] = l_user
  106. st.success("Logged in successfully!")
  107. st.rerun()
  108. else:
  109. st.error("Invalid username or password.")
  110. with tab2:
  111. r_user = st.text_input("Username", key="r_user")
  112. r_pass = st.text_input("Password", type="password", key="r_pass")
  113. if st.button("Register"):
  114. if len(r_pass) < 6:
  115. st.error("Password too short.")
  116. elif register_user(r_user, r_pass):
  117. st.success("Registered successfully! Please log in.")
  118. else:
  119. st.error("Username already exists.")
  120. # Main Application Logic
  121. if not st.session_state["authenticated_user"]:
  122. st.title("🍔 Food AI Local Explorer")
  123. st.info("Please login or register on the sidebar to interact with the LLM.")
  124. st.stop() # Halt execution here, keeping it secure.
  125. # --- Authenticated App ---
  126. st.title("🍔 Food AI Local Explorer")
  127. st.markdown("Interrogate your database leveraging your private secure stack.")
  128. # Checking products via Reader Login path
  129. conn_reader = get_db_connection('app_reader')
  130. if conn_reader:
  131. with conn_reader.cursor() as cursor:
  132. cursor.execute("SELECT COUNT(*) as total FROM products;")
  133. total_products = cursor.fetchone()['total']
  134. st.sidebar.info(f"Database Scope: {total_products} products.")
  135. tab_chat, tab_explore = st.tabs(["💬 AI Chat", "🔍 Food Search & Details"])
  136. with tab_chat:
  137. st.subheader("Chat with the Context")
  138. if "messages" not in st.session_state:
  139. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  140. for msg in st.session_state.messages:
  141. st.chat_message(msg["role"]).write(msg["content"])
  142. if prompt := st.chat_input("Ask about the food items..."):
  143. st.session_state.messages.append({"role": "user", "content": prompt})
  144. st.chat_message("user").write(prompt)
  145. sys_prompt = "You are a helpful data analyst AI. Answer strictly using local data contexts. If you need external data, use the local_web_search tool!"
  146. with st.spinner("Analyzing the dataset locally..."):
  147. try:
  148. # Compile complete conversational history
  149. temp_messages = [{"role": "system", "content": sys_prompt}] + [m for m in st.session_state.messages if m["role"] != "tool"]
  150. # Primary AI inference
  151. response = ollama.chat(
  152. model='mistral',
  153. messages=temp_messages,
  154. tools=[search_tool_schema]
  155. )
  156. # Check if Mistral decided it needs to search the web
  157. if response.get('message', {}).get('tool_calls'):
  158. for tool in response['message']['tool_calls']:
  159. if tool['function']['name'] == 'local_web_search':
  160. query_arg = tool['function']['arguments'].get('query')
  161. st.info(f"🔍 AI is autonomously searching the web for: '{query_arg}'")
  162. # Execute the local web search against SearXNG
  163. search_data = local_web_search(query_arg)
  164. # Append the tool's thought and the raw search results to the session memory
  165. st.session_state.messages.append(response['message'])
  166. st.session_state.messages.append({
  167. 'role': 'tool',
  168. 'content': search_data,
  169. 'name': 'local_web_search'
  170. })
  171. # Feed the web data back into Mistral for the final summarization
  172. temp_messages = [{"role": "system", "content": sys_prompt}] + st.session_state.messages
  173. response = ollama.chat(
  174. model='mistral',
  175. messages=temp_messages
  176. )
  177. ai_reply = response['message']['content']
  178. except Exception as e:
  179. ai_reply = f"Hold on! Engine execution fault: {e}"
  180. st.session_state.messages.append({"role": "assistant", "content": ai_reply})
  181. st.chat_message("assistant").write(ai_reply)
  182. with tab_explore:
  183. st.subheader("Raw Data Search")
  184. search_query = st.text_input("Search Product Name or Ingredient (e.g. 'Nutella' or 'Sugar')")
  185. if st.button("Search Database") and search_query and conn_reader:
  186. with st.spinner("Querying MySQL..."):
  187. try:
  188. with conn_reader.cursor() as cursor:
  189. # Leverage the FULLTEXT INDEX built in init.sql
  190. query = """
  191. SELECT code, product_name, generic_name, brands, ingredients_text
  192. FROM products
  193. WHERE MATCH(product_name, ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  194. LIMIT 50
  195. """
  196. cursor.execute(query, (search_query,))
  197. results = cursor.fetchall()
  198. except Exception as e:
  199. st.error(f"SQL Error: {e}")
  200. results = []
  201. if results:
  202. st.success(f"Found {len(results)} matching records!")
  203. st.dataframe(results, use_container_width=True)
  204. else:
  205. st.warning("No products found matching those terms.")
  206. if conn_reader:
  207. conn_reader.close()