1
0

app.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import streamlit as st
  2. import pymysql
  3. import myloginpath
  4. import ollama
  5. import bcrypt
  6. import requests
  7. import string
  8. import random
  9. import smtplib
  10. from email.message import EmailMessage
  11. import pandas as pd
  12. from unit_converter import UnitConverter
  13. def local_web_search(query: str) -> str:
  14. try:
  15. req = requests.get(f'http://127.0.0.1:8080/search', params={'q': query, 'format': 'json'})
  16. if req.status_code == 200:
  17. data = req.json()
  18. results = data.get('results', [])
  19. if not results: return f"No results found on the web for '{query}'."
  20. snippets = [f"Source: {r.get('url')}\nContent: {r.get('content')}" for r in results[:3]]
  21. return "\n\n".join(snippets)
  22. return "Search engine returned an error."
  23. except Exception as e: return f"Local search engine unreachable: {e}"
  24. search_tool_schema = {
  25. 'type': 'function',
  26. 'function': {
  27. 'name': 'local_web_search',
  28. 'description': 'Search the internet for info not in DB.',
  29. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string'}}, 'required': ['query']},
  30. },
  31. }
  32. def search_nutrition_db(query: str) -> str:
  33. conn = get_db_connection('app_reader')
  34. if not conn: return "Database connection failed."
  35. try:
  36. with conn.cursor() as cursor:
  37. # Query products view via natural language match on core table
  38. sql = """
  39. SELECT c.product_name, m.proteins_100g, m.fat_100g, m.carbohydrates_100g, m.sugars_100g
  40. FROM food_db.products_core c
  41. LEFT JOIN food_db.products_macros m ON c.code = m.code
  42. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  43. LIMIT 5
  44. """
  45. cursor.execute(sql, (query,))
  46. results = cursor.fetchall()
  47. if not results: return f"No database records found for '{query}'."
  48. snippets = []
  49. for r in results:
  50. snippets.append(f"- {r['product_name']}: Protein {r['proteins_100g']}g, Fat {r['fat_100g']}g, Carbs {r['carbohydrates_100g']}g, Sugars {r['sugars_100g']}g (per 100g)")
  51. return "\n".join(snippets)
  52. except Exception as e:
  53. return f"Database query failed: {e}"
  54. finally:
  55. conn.close()
  56. db_search_tool_schema = {
  57. 'type': 'function',
  58. 'function': {
  59. 'name': 'search_nutrition_db',
  60. 'description': 'Search the local medical nutrition database for product macros and ingredients. ALWAYS prioritize this over web search.',
  61. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string', 'description': 'The product or food name to search for (e.g. apple, chicken, bread)'}}, 'required': ['query']},
  62. },
  63. }
  64. def get_db_connection(login_path):
  65. try:
  66. conf = myloginpath.parse(login_path)
  67. return pymysql.connect(
  68. host=conf.get('host', '127.0.0.1'),
  69. user=conf.get('user'),
  70. password=conf.get('password'),
  71. database='food_db',
  72. cursorclass=pymysql.cursors.DictCursor
  73. )
  74. except Exception as e:
  75. st.error(f"Connection Failed: {e}")
  76. return None
  77. def verify_login(username, password):
  78. conn = get_db_connection('app_auth')
  79. if not conn: return False
  80. with conn.cursor() as cursor:
  81. cursor.execute("SELECT password_hash FROM users WHERE username = %s LIMIT 1", (username,))
  82. result = cursor.fetchone()
  83. conn.close()
  84. if result: return bcrypt.checkpw(password.encode('utf-8'), result['password_hash'].encode('utf-8'))
  85. return False
  86. def get_user_id(username):
  87. conn = get_db_connection('app_auth')
  88. if not conn: return None
  89. with conn.cursor() as cursor:
  90. cursor.execute("SELECT id FROM users WHERE username = %s LIMIT 1", (username,))
  91. result = cursor.fetchone()
  92. conn.close()
  93. return result['id'] if result else None
  94. def get_eav_profile(username):
  95. uid = get_user_id(username)
  96. if not uid: return []
  97. conn = get_db_connection('app_auth')
  98. with conn.cursor() as cursor:
  99. cursor.execute("SELECT id, illness_health_condition_diet_dislikes_name as name, illness_health_condition_diet_dislikes_value as value FROM user_health_profiles WHERE user_id = %s", (uid,))
  100. res = cursor.fetchall()
  101. conn.close()
  102. return res
  103. def get_user_limit(username):
  104. conn = get_db_connection('app_auth')
  105. if not conn: return "50"
  106. with conn.cursor() as cursor:
  107. cursor.execute("SELECT search_limit FROM users WHERE username = %s LIMIT 1", (username,))
  108. result = cursor.fetchone()
  109. conn.close()
  110. return result['search_limit'] if (result and result['search_limit']) else "50"
  111. def register_user(username, password, email):
  112. conn = get_db_connection('app_auth')
  113. if not conn: return False
  114. hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  115. try:
  116. with conn.cursor() as cursor:
  117. cursor.execute("INSERT INTO users (username, password_hash, email) VALUES (%s, %s, %s)", (username, hashed, email))
  118. conn.commit()
  119. conn.close()
  120. send_email(email, "Welcome to Local Food AI", f"Hello {username}, your account was securely created!", to_name=username.title())
  121. return True
  122. except pymysql.err.IntegrityError:
  123. return False
  124. def send_email(to_email, subject, body, to_name="User"):
  125. msg = EmailMessage()
  126. msg.set_content(body)
  127. msg['Subject'] = subject
  128. msg['From'] = '"Clinical Food AI System" <security@localfoodai.com>'
  129. msg['To'] = f'"{to_name}" <{to_email}>'
  130. import time
  131. for attempt in range(5):
  132. try:
  133. s = smtplib.SMTP('localhost', 25)
  134. s.send_message(msg)
  135. s.quit()
  136. return True
  137. except Exception as e:
  138. if attempt == 4:
  139. return f"SMTP Delivery Failed: {str(e)}"
  140. time.sleep(2)
  141. return "Unknown Error Occurred"
  142. def reset_password(username, email):
  143. conn = get_db_connection('app_auth')
  144. if not conn: return False
  145. with conn.cursor() as cursor:
  146. cursor.execute("SELECT id, email FROM users WHERE username = %s", (username,))
  147. user = cursor.fetchone()
  148. if user and user['email'] == email:
  149. new_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
  150. hashed = bcrypt.hashpw(new_pass.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  151. cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (hashed, user['id']))
  152. conn.commit()
  153. conn.close()
  154. status = send_email(email, "Password Reset", f"Your new temporary password is: {new_pass}", to_name=username.title())
  155. if status is True:
  156. return True
  157. return status
  158. return False
  159. # UI Theming
  160. st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
  161. st.markdown("""
  162. <style>
  163. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
  164. html, body, [class*="css"] { font-family: 'Inter', sans-serif; background-color: #0b192c; color: #e2e8f0; }
  165. h1, h2, h3 { color: #38bdf8 !important; font-weight: 600; letter-spacing: 0.5px; }
  166. div[data-testid="stSidebar"] { background: rgba(11, 25, 44, 0.95) !important; backdrop-filter: blur(10px); border-right: 1px solid #1e293b; }
  167. .stButton>button { background: linear-gradient(135deg, #0ea5e9, #0284c7); color: white; border: none; border-radius: 6px; }
  168. .stButton>button:hover { transform: scale(1.02); }
  169. .stTextInput>div>div>input, .stNumberInput>div>div>input, .stSelectbox>div>div>div { background-color: #0f172a; color: #f8fafc; border: 1px solid #38bdf8; }
  170. </style>
  171. """, unsafe_allow_html=True)
  172. if "authenticated_user" not in st.session_state:
  173. st.session_state["authenticated_user"] = None
  174. with st.sidebar:
  175. st.title("User Portal 🔐")
  176. if st.session_state["authenticated_user"]:
  177. st.success(f"Logged in as: {st.session_state['authenticated_user']}")
  178. if st.button("Logout"):
  179. st.session_state["authenticated_user"] = None
  180. st.rerun()
  181. st.markdown("---")
  182. st.subheader("🏥 Dynamic Health Profile")
  183. eav_data = get_eav_profile(st.session_state["authenticated_user"])
  184. uid = get_user_id(st.session_state["authenticated_user"])
  185. user_lim = get_user_limit(st.session_state["authenticated_user"])
  186. with st.expander("⚙️ Account Preferences"):
  187. opts = ["10", "20", "50", "100", "All"]
  188. idx = opts.index(user_lim) if user_lim in opts else 2
  189. new_lim = st.selectbox("Default Search Limit", opts, index=idx)
  190. if new_lim != user_lim:
  191. conn = get_db_connection('app_auth')
  192. with conn.cursor() as c:
  193. c.execute("UPDATE users SET search_limit = %s WHERE id = %s", (new_lim, uid))
  194. conn.commit()
  195. st.rerun()
  196. with st.expander("➕ Add Condition / Diet"):
  197. new_cat = st.selectbox("Category", ["Condition", "Illness", "Diet", "Dislike", "Allergy"])
  198. new_val = st.text_input("Value (e.g. 'vegan', 'diabetes', 'broccoli')").strip().lower()
  199. if st.button("Add to Profile") and new_val and uid:
  200. conn = get_db_connection('app_auth')
  201. with conn.cursor() as c:
  202. c.execute("INSERT INTO user_health_profiles (user_id, illness_health_condition_diet_dislikes_name, illness_health_condition_diet_dislikes_value) VALUES (%s, %s, %s)", (uid, new_cat, new_val))
  203. conn.commit()
  204. st.rerun()
  205. if eav_data:
  206. st.markdown("#### Active Flags")
  207. for e in eav_data:
  208. col1, col2 = st.columns([4, 1])
  209. col1.info(f"**{e['name']}:** {e['value'].title()}")
  210. if col2.button("X", key=f"del_eav_{e['id']}"):
  211. conn = get_db_connection('app_auth')
  212. with conn.cursor() as c:
  213. c.execute("DELETE FROM user_health_profiles WHERE id = %s", (e['id'],))
  214. conn.commit()
  215. st.rerun()
  216. else:
  217. tab1, tab2, tab3 = st.tabs(["Login", "Register", "Reset"])
  218. with tab1:
  219. l_user = st.text_input("Username", key="l_user")
  220. l_pass = st.text_input("Password", type="password", key="l_pass")
  221. if st.button("Login"):
  222. if verify_login(l_user, l_pass):
  223. st.session_state["authenticated_user"] = l_user
  224. st.rerun()
  225. else: st.error("Invalid login.")
  226. with tab2:
  227. r_user = st.text_input("Username", key="r_user")
  228. r_email = st.text_input("Email Address", key="r_email")
  229. r_pass = st.text_input("Password", type="password", key="r_pass")
  230. if st.button("Register"):
  231. if len(r_pass) < 6: st.error("Password too short.")
  232. elif register_user(r_user, r_pass, r_email): st.success("Registered safely!")
  233. else: st.error("Username exists.")
  234. with tab3:
  235. f_user = st.text_input("Username", key="f_user")
  236. f_email = st.text_input("Registered Email", key="f_email")
  237. if st.button("Send Reset Link"):
  238. status = reset_password(f_user, f_email)
  239. if status is True:
  240. st.success("Password reset emailed.")
  241. else:
  242. st.error(f"Failed: {status}")
  243. if not st.session_state["authenticated_user"]:
  244. st.title("🍔 Food AI Medical Explorer")
  245. st.info("Please login to interrogate the Clinical Data.")
  246. st.stop()
  247. st.title("🍔 Food AI Clinical Explorer")
  248. conn_reader = get_db_connection('app_reader')
  249. tab_chat, tab_explore, tab_plate, tab_planner = st.tabs(["💬 AI Chat", "🔬 Clinical Search", "🍽️ My Plate Builder", "🤖 AI Meal Planner"])
  250. with tab_chat:
  251. st.subheader("Chat with the Context")
  252. if "messages" not in st.session_state:
  253. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  254. for msg in st.session_state.messages:
  255. st.chat_message(msg["role"]).write(msg["content"])
  256. if prompt := st.chat_input("Ask about the food items..."):
  257. st.session_state.messages.append({"role": "user", "content": prompt})
  258. st.chat_message("user").write(prompt)
  259. sys_prompt = "You are a helpful medical data analyst AI. ALWAYS query the local database using the search_nutrition_db tool to answer questions about food, macros, and nutrients before answering or searching the web! If it's not in the DB, you can use local_web_search."
  260. with st.spinner("Analyzing..."):
  261. try:
  262. temp_messages = [{"role": "system", "content": sys_prompt}] + [m for m in st.session_state.messages if m["role"] != "tool"]
  263. response = ollama.chat(model='mistral', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  264. if response.get('message', {}).get('tool_calls'):
  265. for tool in response['message']['tool_calls']:
  266. if tool['function']['name'] == 'local_web_search':
  267. query_arg = tool['function']['arguments'].get('query')
  268. st.info(f"🔍 Web Search triggered for: '{query_arg}'")
  269. search_data = local_web_search(query_arg)
  270. st.session_state.messages.append(response['message'])
  271. st.session_state.messages.append({'role': 'tool', 'content': search_data, 'name': 'local_web_search'})
  272. elif tool['function']['name'] == 'search_nutrition_db':
  273. query_arg = tool['function']['arguments'].get('query')
  274. st.info(f"🗄️ Database Search triggered for: '{query_arg}'")
  275. db_data = search_nutrition_db(query_arg)
  276. st.session_state.messages.append(response['message'])
  277. st.session_state.messages.append({'role': 'tool', 'content': db_data, 'name': 'search_nutrition_db'})
  278. temp_messages = [{"role": "system", "content": sys_prompt}] + st.session_state.messages
  279. response = ollama.chat(model='mistral', messages=temp_messages)
  280. ai_reply = response['message']['content']
  281. except Exception as e: ai_reply = f"Hold on! Engine execution fault: {e}"
  282. st.session_state.messages.append({"role": "assistant", "content": ai_reply})
  283. st.chat_message("assistant").write(ai_reply)
  284. def highlight_medical_warnings(row):
  285. try:
  286. val = str(row.get('Medical Warning', ''))
  287. if '⚠️' in val: return ['background-color: rgba(255, 0, 0, 0.4); color: white;'] * len(row)
  288. if '💚' in val: return ['background-color: rgba(0, 255, 0, 0.3); color: white;'] * len(row)
  289. except: pass
  290. return [''] * len(row)
  291. with tab_explore:
  292. st.subheader("Clinical Data Search")
  293. sq = st.text_input("Search Product Name or Ingredient")
  294. cols = st.columns(5)
  295. min_pro = cols[0].number_input("Min Protein (g)", 0, 1000, 0)
  296. min_fat = cols[1].number_input("Min Fat (g)", 0, 1000, 0)
  297. min_carb = cols[2].number_input("Min Carbs (g)", 0, 1000, 0)
  298. max_sug = cols[3].number_input("Max Sugar (g)", 0, 1000, 1000)
  299. # Load dynamically fetched limit as index
  300. opts = [10, 20, 50, 100, "All"]
  301. user_lim_str = get_user_limit(st.session_state["authenticated_user"])
  302. user_lim_val = "All" if user_lim_str == "All" else int(user_lim_str)
  303. idx = opts.index(user_lim_val) if user_lim_val in opts else 2
  304. limit_rc = cols[4].selectbox("Limit Results", opts, index=idx)
  305. if st.button("Search Database") and sq and conn_reader:
  306. with st.spinner("Processing massive clinical query..."):
  307. try:
  308. with conn_reader.cursor() as cursor:
  309. l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
  310. query = f"""
  311. SELECT c.code, c.product_name, c.generic_name, c.brands, c.ingredients_text,
  312. a.allergens,
  313. m.`energy-kcal_100g`, m.proteins_100g, m.fat_100g, m.carbohydrates_100g, m.sugars_100g, m.fiber_100g, m.sodium_100g, m.salt_100g, m.cholesterol_100g,
  314. v.`vitamin-a_100g`, v.`vitamin-b1_100g`, v.`vitamin-b2_100g`, v.`vitamin-pp_100g`, v.`vitamin-b6_100g`, v.`vitamin-b9_100g`, v.`vitamin-b12_100g`, v.`vitamin-c_100g`, v.`vitamin-d_100g`, v.`vitamin-e_100g`, v.`vitamin-k_100g`,
  315. min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
  316. FROM food_db.products_core c
  317. LEFT JOIN food_db.products_allergens a ON c.code = a.code
  318. LEFT JOIN food_db.products_macros m ON c.code = m.code
  319. LEFT JOIN food_db.products_vitamins v ON c.code = v.code
  320. LEFT JOIN food_db.products_minerals min ON c.code = min.code
  321. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  322. AND (m.proteins_100g >= %s OR m.proteins_100g IS NULL)
  323. AND (m.fat_100g >= %s OR m.fat_100g IS NULL)
  324. AND (m.carbohydrates_100g >= %s OR m.carbohydrates_100g IS NULL)
  325. AND (m.sugars_100g <= %s OR m.sugars_100g IS NULL)
  326. {l_str}
  327. """
  328. cursor.execute(query, (sq, min_pro, min_fat, min_carb, max_sug))
  329. results = cursor.fetchall()
  330. if results:
  331. # Fetch EAV Medical Profile
  332. eav_profile = get_eav_profile(st.session_state["authenticated_user"])
  333. df = pd.DataFrame(results)
  334. st.markdown("### 🛠️ Dynamic Column Display")
  335. default_columns = [
  336. 'code', 'product_name', 'generic_name', 'brands', 'allergens', 'ingredients_text',
  337. 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'sodium_100g', 'energy-kcal_100g',
  338. 'vitamin-c_100g', 'iron_100g', 'calcium_100g'
  339. ]
  340. all_fetched_cols = list(df.columns)
  341. valid_defaults = [c for c in default_columns if c in all_fetched_cols]
  342. if "selected_columns" not in st.session_state or st.button("Reset Default Columns"):
  343. st.session_state["selected_columns"] = valid_defaults
  344. st.rerun()
  345. chosen_cols = st.multiselect("Customize Dataset View", all_fetched_cols, default=st.session_state["selected_columns"], key="multi_cols")
  346. st.session_state["selected_columns"] = chosen_cols
  347. # Filter dataframe gracefully, but we retain a copy for background analytics
  348. df_display = df[chosen_cols].copy()
  349. warnings_col = []
  350. for idx, row in df.iterrows():
  351. warns = []
  352. ing_text = str(row['ingredients_text']).lower()
  353. all_text = str(row['allergens']).lower()
  354. for param in eav_profile:
  355. cat = param['name'].lower()
  356. val = param['value']
  357. # Disease Analytics
  358. if cat == 'illness':
  359. if val == 'diabetes' and pd.notnull(row.get('sugars_100g')) and float(row['sugars_100g']) > 10.0:
  360. warns.append("⚠️ High Sugar (Diabetes)")
  361. if (val == 'hypertension' or val == 'high bp') and pd.notnull(row.get('sodium_100g')) and float(row['sodium_100g']) > 1.5:
  362. warns.append("⚠️ High Salt (Hypertension)")
  363. if val == 'scurvy' and pd.notnull(row.get('vitamin-c_100g')) and float(row['vitamin-c_100g']) > 0.005:
  364. warns.append("💚 High Vitamin C (Scurvy Recommended)")
  365. if val == 'anemia' and pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  366. warns.append("💚 High Iron (Anemia Recommended)")
  367. # Condition Analytics
  368. if cat == 'condition':
  369. if val == 'pregnant':
  370. if ('cru' in ing_text or 'raw' in ing_text or 'viande crue' in ing_text):
  371. warns.append("⚠️ Raw Foods (Pregnancy Toxoplasmosis)")
  372. if pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  373. warns.append("💚 Med-High Iron (Pregnancy Health)")
  374. if val == 'low fat' and pd.notnull(row.get('fat_100g')) and float(row['fat_100g']) > 20.0:
  375. warns.append("⚠️ High Fat")
  376. if val == 'osteoporosis' and pd.notnull(row.get('calcium_100g')) and float(row['calcium_100g']) > 0.1:
  377. warns.append("💚 High Calcium (Bone Health)")
  378. if eav_data:
  379. ing_text = str(row.get('ingredients_text', '')).lower()
  380. all_text = str(row.get('allergens', '')).lower()
  381. for e in eav_data:
  382. cat = str(e['name']).lower()
  383. val = str(e['value']).lower()
  384. # Clinical Trace Checks...
  385. if cat == 'condition' and val == 'pregnant':
  386. if float(row.get('iron_100g', 0) or 0) < 0.003:
  387. warns.append("⚠️ Low Iron (Pregnancy Risk)")
  388. else:
  389. warns.append("💚 Recommended (High Iron)")
  390. if cat == 'illness' and val == 'osteoporosis':
  391. if float(row.get('calcium_100g', 0) or 0) < 0.120:
  392. warns.append("⚠️ Low Calcium (Osteoporosis Risk)")
  393. else:
  394. warns.append("💚 Recommended (High Calcium)")
  395. if cat == 'illness' and val == 'scurvy':
  396. if float(row.get('vitamin-c_100g', 0) or 0) < 0.010:
  397. warns.append("⚠️ Low Vitamin C (Scurvy Risk)")
  398. else:
  399. warns.append("💚 Recommended (High Vitamin C)")
  400. if cat == 'diet' and val in ['vegan', 'vegetarian']:
  401. if any(x in ing_text for x in ['meat', 'beef', 'chicken', 'fish', 'gelatin', 'whey']):
  402. warns.append("⚠️ Contains Animal Products")
  403. if cat == 'diet' and val == 'halal':
  404. if any(x in ing_text for x in ['pork', 'pig', 'wine', 'alcohol', 'beer']):
  405. warns.append("⚠️ Probable Haram Ingredients (e.g. Pork/Wine)")
  406. if cat in ['dislike', 'allergy']:
  407. if val in ing_text or val in all_text:
  408. warns.append(f"⚠️ Contains: {val.upper()}")
  409. warnings_col.append(" | ".join(list(set(warns))) if warns else "✅ Safe for Profile")
  410. df_display.insert(0, 'Medical Warning', warnings_col)
  411. styled_df = df_display.style.apply(highlight_medical_warnings, axis=1)
  412. st.success(f"Analysed {len(results)} records utilizing dynamic Partitions!")
  413. st.dataframe(styled_df, use_container_width=True)
  414. else:
  415. st.warning("No products found matching those strict terms.")
  416. except Exception as e: st.error(f"SQL/Pandas Error: {e}")
  417. with tab_plate:
  418. st.subheader("🍽️ My Plate Builder")
  419. uid = get_user_id(st.session_state["authenticated_user"])
  420. conn = get_db_connection('app_auth')
  421. if conn and uid:
  422. with conn.cursor() as cursor:
  423. cursor.execute("SELECT id, plate_name FROM plates WHERE user_id = %s", (uid,))
  424. plates = cursor.fetchall()
  425. with st.expander("➕ Create a New Plate"):
  426. new_plate_name = st.text_input("Plate Name")
  427. if st.button("Create Plate"):
  428. cursor.execute("INSERT INTO plates (user_id, plate_name) VALUES (%s, %s)", (uid, new_plate_name))
  429. conn.commit()
  430. st.rerun()
  431. if plates:
  432. selected_plate = st.selectbox("Select Active Plate", [p['plate_name'] for p in plates])
  433. active_p_id = next(p['id'] for p in plates if p['plate_name'] == selected_plate)
  434. cursor.execute("""
  435. SELECT i.id, i.product_code, i.quantity_grams, p.product_name, p.proteins_100g, p.fat_100g, p.carbohydrates_100g
  436. FROM plate_items i LEFT JOIN products p ON i.product_code = p.code WHERE i.plate_id = %s
  437. """, (active_p_id,))
  438. items = cursor.fetchall()
  439. if items:
  440. st.dataframe(items, use_container_width=True)
  441. total_pro = sum((float(i['proteins_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  442. total_fat = sum((float(i['fat_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  443. total_carb = sum((float(i['carbohydrates_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  444. st.info(f"**Total Protein:** {total_pro:.1f}g | **Total Fat:** {total_fat:.1f}g | **Total Carbs:** {total_carb:.1f}g")
  445. st.markdown("---")
  446. st.markdown("#### ➕ Add Food to Plate")
  447. add_search = st.text_input("Search Product Name")
  448. if add_search:
  449. cursor.execute("SELECT code, product_name FROM food_db.products_core WHERE MATCH(product_name, ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE) LIMIT 10", (add_search,))
  450. search_res = cursor.fetchall()
  451. if search_res:
  452. options = {f"{r['product_name']} ({r['code']})": r for r in search_res}
  453. selected_str = st.selectbox("Select Product", list(options.keys()))
  454. selected_product = options[selected_str]
  455. add_amount_str = st.text_input("Portion Quantity (e.g., '100g', '2 tbsp', '1.5 cups', '1 pinch')", value="100g")
  456. if st.button("Add Item to Plate"):
  457. # Use UnitConverter to parse
  458. grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
  459. if grams is not None:
  460. cursor.execute("INSERT INTO plate_items (plate_id, product_code, quantity_grams) VALUES (%s, %s, %s)",
  461. (active_p_id, selected_product['code'], grams))
  462. conn.commit()
  463. st.success(f"Added {grams}g of {selected_product['product_name']}!")
  464. st.rerun()
  465. else:
  466. st.error("Could not parse unit. Please use format like '100g' or '1 cup'.")
  467. else:
  468. st.warning("No products found.")
  469. with tab_planner:
  470. st.subheader("🤖 AI Meal Planner")
  471. p_col1, p_col2, p_col3 = st.columns(3)
  472. target_cal = p_col1.number_input("Target Daily Calories (kcal)", 1000, 5000, 2000, 50)
  473. diet_pref = p_col2.selectbox("Dietary Preference", ["Omnivore", "Vegetarian", "Vegan", "Keto", "Paleo"])
  474. meal_count = p_col3.slider("Number of Meals", 2, 6, 3)
  475. extra_notes = st.text_input("Any additional allergies or goals?")
  476. if st.button("Generate Professional Menu"):
  477. with st.spinner("AI is formulating and interrogating the local database..."):
  478. sys_prompt = f"""You are a professional Dietitian planner. Target: {target_cal}kcal over {meal_count} meals.
  479. Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
  480. CRITICAL INSTRUCTIONS:
  481. - YOU MUST USE the `search_nutrition_db` tool to find real products and their exact macros before constructing the menu!
  482. - If you cannot find appropriate products in the local DB, use `local_web_search`.
  483. - ALWAYS output exactly as a strict Markdown table including Columns: | Meal | Food | Calories | Salt | Fat | Iron |
  484. - DO NOT output | separated text outside of standard strict markdown block ` ```markdown ` or standard rendering.
  485. - Convert ALL cooking measurements to Grams (g). Use these equivalents STRICTLY:
  486. 1 tbsp = 15g, 1 tsp = 5g, 1 cup = 200g, 1 mustard glass = 100g. 1 cl of liquid = 10g.
  487. """
  488. temp_messages = [{'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': 'Generate my meal plan. Find real foods from the DB.'}]
  489. response = ollama.chat(model='mistral', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  490. # Simple loop to handle multiple tool calls (up to 3 times to prevent infinite loops)
  491. for _ in range(3):
  492. if response.get('message', {}).get('tool_calls'):
  493. temp_messages.append(response['message'])
  494. for tool in response['message']['tool_calls']:
  495. if tool['function']['name'] == 'local_web_search':
  496. query_arg = tool['function']['arguments'].get('query')
  497. st.info(f"🔍 Planner Web Search triggered for: '{query_arg}'")
  498. search_data = local_web_search(query_arg)
  499. temp_messages.append({'role': 'tool', 'content': search_data, 'name': 'local_web_search'})
  500. elif tool['function']['name'] == 'search_nutrition_db':
  501. query_arg = tool['function']['arguments'].get('query')
  502. st.info(f"🗄️ Planner DB Search triggered for: '{query_arg}'")
  503. db_data = search_nutrition_db(query_arg)
  504. temp_messages.append({'role': 'tool', 'content': db_data, 'name': 'search_nutrition_db'})
  505. response = ollama.chat(model='mistral', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  506. else:
  507. break
  508. st.markdown(response['message']['content'])
  509. if conn_reader: conn_reader.close()