app.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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 get_user_id(username):
  73. conn = get_db_connection('app_auth')
  74. if not conn: return None
  75. with conn.cursor() as cursor:
  76. cursor.execute("SELECT id FROM users WHERE username = %s LIMIT 1", (username,))
  77. result = cursor.fetchone()
  78. conn.close()
  79. return result['id'] if result else None
  80. def register_user(username, password):
  81. conn = get_db_connection('app_auth')
  82. if not conn: return False
  83. hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  84. try:
  85. with conn.cursor() as cursor:
  86. cursor.execute("INSERT INTO users (username, password_hash) VALUES (%s, %s)", (username, hashed))
  87. conn.commit()
  88. conn.close()
  89. return True
  90. except pymysql.err.IntegrityError:
  91. return False # Username exists
  92. # -------------------------------------------------------------------
  93. # UI Flow
  94. # -------------------------------------------------------------------
  95. st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
  96. # Scientific Medical Theming (CSS Injection)
  97. st.markdown("""
  98. <style>
  99. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
  100. html, body, [class*="css"] {
  101. font-family: 'Inter', sans-serif;
  102. background-color: #0b192c;
  103. color: #e2e8f0;
  104. }
  105. h1, h2, h3 {
  106. color: #38bdf8 !important;
  107. font-weight: 600;
  108. letter-spacing: 0.5px;
  109. }
  110. div[data-testid="stSidebar"] {
  111. background: rgba(11, 25, 44, 0.95) !important;
  112. backdrop-filter: blur(10px);
  113. border-right: 1px solid #1e293b;
  114. }
  115. .stButton>button {
  116. background: linear-gradient(135deg, #0ea5e9, #0284c7);
  117. color: white;
  118. border: none;
  119. border-radius: 6px;
  120. box-shadow: 0 4px 10px rgba(2, 132, 199, 0.3);
  121. transition: transform 0.2s, box-shadow 0.2s;
  122. }
  123. .stButton>button:hover {
  124. transform: scale(1.02);
  125. box-shadow: 0 6px 15px rgba(2, 132, 199, 0.5);
  126. }
  127. .stTextInput>div>div>input, .stNumberInput>div>div>input {
  128. background-color: #0f172a;
  129. color: #f8fafc;
  130. border: 1px solid #38bdf8;
  131. border-radius: 6px;
  132. }
  133. .stTabs [data-baseweb="tab"] {
  134. color: #94a3b8;
  135. }
  136. .stTabs [aria-selected="true"] {
  137. color: #38bdf8 !important;
  138. border-bottom-color: #38bdf8 !important;
  139. }
  140. </style>
  141. """, unsafe_allow_html=True)
  142. if "authenticated_user" not in st.session_state:
  143. st.session_state["authenticated_user"] = None
  144. # Sidebar Authentication
  145. with st.sidebar:
  146. st.title("User Portal 🔐")
  147. if st.session_state["authenticated_user"]:
  148. st.success(f"Logged in as: {st.session_state['authenticated_user']}")
  149. if st.button("Logout"):
  150. st.session_state["authenticated_user"] = None
  151. st.rerun()
  152. else:
  153. tab1, tab2 = st.tabs(["Login", "Register"])
  154. with tab1:
  155. l_user = st.text_input("Username", key="l_user")
  156. l_pass = st.text_input("Password", type="password", key="l_pass")
  157. if st.button("Login"):
  158. if verify_login(l_user, l_pass):
  159. st.session_state["authenticated_user"] = l_user
  160. st.success("Logged in successfully!")
  161. st.rerun()
  162. else:
  163. st.error("Invalid username or password.")
  164. with tab2:
  165. r_user = st.text_input("Username", key="r_user")
  166. r_pass = st.text_input("Password", type="password", key="r_pass")
  167. if st.button("Register"):
  168. if len(r_pass) < 6:
  169. st.error("Password too short.")
  170. elif register_user(r_user, r_pass):
  171. st.success("Registered successfully! Please log in.")
  172. else:
  173. st.error("Username already exists.")
  174. # Main Application Logic
  175. if not st.session_state["authenticated_user"]:
  176. st.title("🍔 Food AI Local Explorer")
  177. st.info("Please login or register on the sidebar to interact with the LLM.")
  178. st.stop() # Halt execution here, keeping it secure.
  179. # --- Authenticated App ---
  180. st.title("🍔 Food AI Local Explorer")
  181. st.markdown("Interrogate your database leveraging your private secure stack.")
  182. # Checking products via Reader Login path
  183. conn_reader = get_db_connection('app_reader')
  184. if conn_reader:
  185. with conn_reader.cursor() as cursor:
  186. cursor.execute("SELECT COUNT(*) as total FROM products;")
  187. total_products = cursor.fetchone()['total']
  188. st.sidebar.info(f"Database Scope: {total_products} products.")
  189. tab_chat, tab_explore, tab_plate = st.tabs(["💬 AI Chat", "🔬 Scientific Nutrients Search", "🍽️ My Plate Combinations"])
  190. with tab_chat:
  191. st.subheader("Chat with the Context")
  192. if "messages" not in st.session_state:
  193. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  194. for msg in st.session_state.messages:
  195. st.chat_message(msg["role"]).write(msg["content"])
  196. if prompt := st.chat_input("Ask about the food items..."):
  197. st.session_state.messages.append({"role": "user", "content": prompt})
  198. st.chat_message("user").write(prompt)
  199. 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!"
  200. with st.spinner("Analyzing the dataset locally..."):
  201. try:
  202. # Compile complete conversational history
  203. temp_messages = [{"role": "system", "content": sys_prompt}] + [m for m in st.session_state.messages if m["role"] != "tool"]
  204. # Primary AI inference
  205. response = ollama.chat(
  206. model='mistral',
  207. messages=temp_messages,
  208. tools=[search_tool_schema]
  209. )
  210. # Check if Mistral decided it needs to search the web
  211. if response.get('message', {}).get('tool_calls'):
  212. for tool in response['message']['tool_calls']:
  213. if tool['function']['name'] == 'local_web_search':
  214. query_arg = tool['function']['arguments'].get('query')
  215. st.info(f"🔍 AI is autonomously searching the web for: '{query_arg}'")
  216. # Execute the local web search against SearXNG
  217. search_data = local_web_search(query_arg)
  218. # Append the tool's thought and the raw search results to the session memory
  219. st.session_state.messages.append(response['message'])
  220. st.session_state.messages.append({
  221. 'role': 'tool',
  222. 'content': search_data,
  223. 'name': 'local_web_search'
  224. })
  225. # Feed the web data back into Mistral for the final summarization
  226. temp_messages = [{"role": "system", "content": sys_prompt}] + st.session_state.messages
  227. response = ollama.chat(
  228. model='mistral',
  229. messages=temp_messages
  230. )
  231. ai_reply = response['message']['content']
  232. except Exception as e:
  233. ai_reply = f"Hold on! Engine execution fault: {e}"
  234. st.session_state.messages.append({"role": "assistant", "content": ai_reply})
  235. st.chat_message("assistant").write(ai_reply)
  236. with tab_explore:
  237. st.subheader("Raw Data Search")
  238. search_query = st.text_input("Search Product Name or Ingredient (e.g. 'Nutella' or 'Sugar')")
  239. st.markdown("### 🧬 Filter by Macronutrients")
  240. cols = st.columns(4)
  241. min_pro = cols[0].number_input("Min Protein (g)", 0, 1000, 0)
  242. min_fat = cols[1].number_input("Min Fat (g)", 0, 1000, 0)
  243. min_carb = cols[2].number_input("Min Carbs (g)", 0, 1000, 0)
  244. max_sug = cols[3].number_input("Max Sugar (g)", 0, 1000, 1000)
  245. if st.button("Search Database") and search_query and conn_reader:
  246. with st.spinner("Querying MySQL..."):
  247. try:
  248. with conn_reader.cursor() as cursor:
  249. # Leverage the FULLTEXT INDEX and dynamically parsed pandas schema
  250. query = """
  251. SELECT code, product_name, generic_name, brands,
  252. proteins_100g, fat_100g, carbohydrates_100g, sugars_100g, energy_kcal_100g
  253. FROM products
  254. WHERE MATCH(product_name, ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  255. AND (proteins_100g >= %s OR proteins_100g IS NULL)
  256. AND (fat_100g >= %s OR fat_100g IS NULL)
  257. AND (carbohydrates_100g >= %s OR carbohydrates_100g IS NULL)
  258. AND (sugars_100g <= %s OR sugars_100g IS NULL)
  259. LIMIT 50
  260. """
  261. cursor.execute(query, (search_query, min_pro, min_fat, min_carb, max_sug))
  262. results = cursor.fetchall()
  263. except Exception as e:
  264. st.error(f"SQL Error: {e} (Has the background ingestion script created the new full schema yet?)")
  265. results = []
  266. if results:
  267. st.success(f"Found {len(results)} matching records! (Use product 'code' to add to your Plate)")
  268. st.dataframe(results, use_container_width=True)
  269. else:
  270. st.warning("No products found matching those strict terms.")
  271. with tab_plate:
  272. st.subheader("🍽️ My Plate Builder")
  273. st.markdown("Create a mapped collection of foods to calculate compounding total nutritional values.")
  274. uid = get_user_id(st.session_state["authenticated_user"])
  275. if not uid:
  276. st.warning("Authentication link failed.")
  277. else:
  278. conn = get_db_connection('app_auth')
  279. if conn:
  280. with conn.cursor() as cursor:
  281. # Get the user's active plates
  282. cursor.execute("SELECT id, plate_name FROM plates WHERE user_id = %s", (uid,))
  283. plates = cursor.fetchall()
  284. with st.expander("➕ Create a New Plate"):
  285. new_plate_name = st.text_input("Plate Name (e.g., 'Bulking Meal')")
  286. if st.button("Create Plate"):
  287. cursor.execute("INSERT INTO plates (user_id, plate_name) VALUES (%s, %s)", (uid, new_plate_name))
  288. conn.commit()
  289. st.success("New plate established in the database!")
  290. st.rerun()
  291. if plates:
  292. selected_plate = st.selectbox("Select Active Plate", [p['plate_name'] for p in plates])
  293. active_p_id = next(p['id'] for p in plates if p['plate_name'] == selected_plate)
  294. st.markdown(f"### Current Items in `{selected_plate}`")
  295. try:
  296. cursor.execute("""
  297. SELECT i.id, i.product_code, i.quantity_grams, p.product_name, p.proteins_100g, p.fat_100g, p.carbohydrates_100g
  298. FROM plate_items i
  299. LEFT JOIN products p ON i.product_code = p.code
  300. WHERE i.plate_id = %s
  301. """, (active_p_id,))
  302. items = cursor.fetchall()
  303. if items:
  304. st.dataframe(items, use_container_width=True)
  305. # Aggregate total logic mapping grams relative to 100g baseline
  306. total_pro = sum((float(i['proteins_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  307. total_fat = sum((float(i['fat_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  308. total_carb = sum((float(i['carbohydrates_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  309. st.markdown("### 📊 Combined Nutritional Value")
  310. st.info(f"**Total Protein:** {total_pro:.1f}g | **Total Fat:** {total_fat:.1f}g | **Total Carbs:** {total_carb:.1f}g")
  311. else:
  312. st.write("Plate is empty. Switch to the Search tab, find a tracking 'code', and add it below!")
  313. except Exception as e:
  314. st.error(f"Cannot render plate items until dynamic product schema exists. {e}")
  315. st.markdown("---")
  316. st.markdown("### Add Food to Plate")
  317. add_code = st.text_input("Enter exact Product `code` (Find this in the Search tab)")
  318. add_grams = st.number_input("Portion Quantity (Grams)", min_value=1.0, value=100.0)
  319. if st.button("Add Item to Plate"):
  320. cursor.execute("INSERT INTO plate_items (plate_id, product_code, quantity_grams) VALUES (%s, %s, %s)",
  321. (active_p_id, add_code, add_grams))
  322. conn.commit()
  323. st.success("Item logically attached to plate!")
  324. st.rerun()
  325. if conn_reader:
  326. conn_reader.close()