app.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  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. from snmp_notifier import notifier
  14. def local_web_search(query: str) -> str:
  15. try:
  16. req = requests.get(f'http://127.0.0.1:8080/search', params={'q': query, 'format': 'json'})
  17. if req.status_code == 200:
  18. data = req.json()
  19. results = data.get('results', [])
  20. if not results: return f"No results found on the web for '{query}'."
  21. snippets = [f"Source: {r.get('url')}\nContent: {r.get('content')}" for r in results[:3]]
  22. return "\n\n".join(snippets)
  23. return "Search engine returned an error."
  24. except Exception as e: return f"Local search engine unreachable: {e}"
  25. search_tool_schema = {
  26. 'type': 'function',
  27. 'function': {
  28. 'name': 'local_web_search',
  29. 'description': 'Search the internet for info not in DB.',
  30. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string'}}, 'required': ['query']},
  31. },
  32. }
  33. def search_nutrition_db(query: str) -> str:
  34. conn = get_db_connection('app_reader')
  35. if not conn: return "Database connection failed."
  36. try:
  37. with conn.cursor() as cursor:
  38. # Query products view via natural language match on core table
  39. sql = """
  40. SELECT c.product_name, m.proteins_100g, m.fat_100g, m.carbohydrates_100g, m.sugars_100g
  41. FROM food_db.products_core c
  42. LEFT JOIN food_db.products_macros m ON c.code = m.code
  43. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  44. AND c.product_name IS NOT NULL AND c.product_name != ''
  45. LIMIT 5
  46. """
  47. cursor.execute(sql, (query,))
  48. results = cursor.fetchall()
  49. if not results: return f"No database records found for '{query}'."
  50. snippets = []
  51. for r in results:
  52. 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)")
  53. return "\n".join(snippets)
  54. except Exception as e:
  55. return f"Database query failed: {e}"
  56. finally:
  57. conn.close()
  58. db_search_tool_schema = {
  59. 'type': 'function',
  60. 'function': {
  61. 'name': 'search_nutrition_db',
  62. 'description': 'Search the local medical nutrition database for product macros and ingredients. ALWAYS prioritize this over web search.',
  63. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string', 'description': 'The product or food name to search for (e.g. apple, chicken, bread)'}}, 'required': ['query']},
  64. },
  65. }
  66. def get_db_connection(login_path):
  67. try:
  68. conf = myloginpath.parse(login_path)
  69. if not conf or not conf.get('user'):
  70. st.error(f"⚠️ MySQL configuration missing for `{login_path}`. If you are testing locally on Windows, this app must be run on the Ubuntu server where `mysql_config_editor` is properly configured.")
  71. return None
  72. return pymysql.connect(
  73. host=conf.get('host', '127.0.0.1'),
  74. user=conf.get('user'),
  75. password=conf.get('password'),
  76. database='food_db',
  77. cursorclass=pymysql.cursors.DictCursor
  78. )
  79. except Exception as e:
  80. st.error(f"Connection Failed: {e}")
  81. return None
  82. def verify_login(username, password):
  83. conn = get_db_connection('app_auth')
  84. if not conn: return False
  85. with conn.cursor() as cursor:
  86. cursor.execute("SELECT password_hash FROM users WHERE username = %s LIMIT 1", (username,))
  87. result = cursor.fetchone()
  88. conn.close()
  89. if result: return bcrypt.checkpw(password.encode('utf-8'), result['password_hash'].encode('utf-8'))
  90. return False
  91. def get_user_id(username):
  92. conn = get_db_connection('app_auth')
  93. if not conn: return None
  94. with conn.cursor() as cursor:
  95. cursor.execute("SELECT id FROM users WHERE username = %s LIMIT 1", (username,))
  96. result = cursor.fetchone()
  97. conn.close()
  98. return result['id'] if result else None
  99. def get_eav_profile(username):
  100. uid = get_user_id(username)
  101. if not uid: return []
  102. conn = get_db_connection('app_auth')
  103. with conn.cursor() as cursor:
  104. 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,))
  105. res = cursor.fetchall()
  106. conn.close()
  107. return res
  108. def get_user_limit(username):
  109. conn = get_db_connection('app_auth')
  110. if not conn: return "50"
  111. with conn.cursor() as cursor:
  112. cursor.execute("SELECT search_limit FROM users WHERE username = %s LIMIT 1", (username,))
  113. result = cursor.fetchone()
  114. conn.close()
  115. return result['search_limit'] if (result and result['search_limit']) else "50"
  116. def register_user(username, password, email):
  117. conn = get_db_connection('app_auth')
  118. if not conn: return False
  119. hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  120. try:
  121. with conn.cursor() as cursor:
  122. cursor.execute("INSERT INTO users (username, password_hash, email) VALUES (%s, %s, %s)", (username, hashed, email))
  123. conn.commit()
  124. conn.close()
  125. send_email(email, "Welcome to Local Food AI", f"Hello {username}, your account was securely created!", to_name=username.title())
  126. return True
  127. except pymysql.err.IntegrityError:
  128. return False
  129. def send_email(to_email, subject, body, to_name="User"):
  130. msg = EmailMessage()
  131. msg.set_content(body)
  132. msg['Subject'] = subject
  133. msg['From'] = '"Clinical Food AI System" <security@localfoodai.com>'
  134. msg['To'] = f'"{to_name}" <{to_email}>'
  135. import time
  136. for attempt in range(5):
  137. try:
  138. s = smtplib.SMTP('localhost', 25)
  139. s.send_message(msg)
  140. s.quit()
  141. return True
  142. except Exception as e:
  143. if attempt == 4:
  144. return f"SMTP Delivery Failed: {str(e)}"
  145. time.sleep(2)
  146. return "Unknown Error Occurred"
  147. def reset_password(username, email):
  148. conn = get_db_connection('app_auth')
  149. if not conn: return False
  150. with conn.cursor() as cursor:
  151. cursor.execute("SELECT id, email FROM users WHERE username = %s", (username,))
  152. user = cursor.fetchone()
  153. if user and user['email'] == email:
  154. new_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
  155. hashed = bcrypt.hashpw(new_pass.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  156. cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (hashed, user['id']))
  157. conn.commit()
  158. conn.close()
  159. status = send_email(email, "Password Reset", f"Your new temporary password is: {new_pass}", to_name=username.title())
  160. if status is True:
  161. return True
  162. return status
  163. return False
  164. # UI Theming
  165. def render_version():
  166. import os, datetime
  167. file_time = datetime.datetime.fromtimestamp(os.path.getmtime(__file__)).strftime('%Y-%m-%d %H:%M:%S')
  168. st.markdown("---")
  169. st.caption("🚀 Version: v1.3.0")
  170. st.caption(f"📅 Last Updated: {file_time}")
  171. st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
  172. st.markdown("""
  173. <style>
  174. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
  175. html, body, [class*="css"] { font-family: 'Inter', sans-serif; background-color: #0b192c; color: #e2e8f0; }
  176. h1, h2, h3 { color: #38bdf8 !important; font-weight: 600; letter-spacing: 0.5px; }
  177. div[data-testid="stSidebar"] { background: rgba(11, 25, 44, 0.95) !important; backdrop-filter: blur(10px); border-right: 1px solid #1e293b; }
  178. .stButton>button { background: linear-gradient(135deg, #0ea5e9, #0284c7); color: white; border: none; border-radius: 6px; }
  179. .stButton>button:hover { transform: scale(1.02); }
  180. .stTextInput>div>div>input, .stNumberInput>div>div>input, .stSelectbox>div>div>div { background-color: #0f172a; color: #f8fafc; border: 1px solid #38bdf8; }
  181. </style>
  182. """, unsafe_allow_html=True)
  183. if "authenticated_user" not in st.session_state:
  184. st.session_state["authenticated_user"] = None
  185. with st.sidebar:
  186. st.title("User Portal 🔐")
  187. if st.session_state["authenticated_user"]:
  188. st.success(f"Logged in as: {st.session_state['authenticated_user']}")
  189. if st.button("Logout"):
  190. st.session_state["authenticated_user"] = None
  191. st.rerun()
  192. st.markdown("---")
  193. st.subheader("🏥 Dynamic Health Profile")
  194. eav_data = get_eav_profile(st.session_state["authenticated_user"])
  195. uid = get_user_id(st.session_state["authenticated_user"])
  196. user_lim = get_user_limit(st.session_state["authenticated_user"])
  197. with st.expander("⚙️ Account Preferences"):
  198. opts = ["10", "20", "50", "100", "All"]
  199. idx = opts.index(user_lim) if user_lim in opts else 2
  200. new_lim = st.selectbox("Default Search Limit", opts, index=idx)
  201. if new_lim != user_lim:
  202. conn = get_db_connection('app_auth')
  203. with conn.cursor() as c:
  204. c.execute("UPDATE users SET search_limit = %s WHERE id = %s", (new_lim, uid))
  205. conn.commit()
  206. st.rerun()
  207. with st.expander("➕ Add Condition / Diet"):
  208. new_cat = st.selectbox("Category", ["Condition", "Illness", "Diet", "Dislike", "Allergy"])
  209. new_val = st.text_input("Value (e.g. 'vegan', 'diabetes', 'broccoli')").strip().lower()
  210. if st.button("Add to Profile") and new_val and uid:
  211. conn = get_db_connection('app_auth')
  212. with conn.cursor() as c:
  213. 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))
  214. conn.commit()
  215. st.rerun()
  216. if eav_data:
  217. st.markdown("#### Active Flags")
  218. for e in eav_data:
  219. col1, col2 = st.columns([4, 1])
  220. col1.info(f"**{e['name']}:** {e['value'].title()}")
  221. if col2.button("X", key=f"del_eav_{e['id']}"):
  222. conn = get_db_connection('app_auth')
  223. with conn.cursor() as c:
  224. c.execute("DELETE FROM user_health_profiles WHERE id = %s", (e['id'],))
  225. conn.commit()
  226. st.rerun()
  227. render_version()
  228. else:
  229. tab1, tab2, tab3 = st.tabs(["Login", "Register", "Reset"])
  230. with tab1:
  231. l_user = st.text_input("Username", key="l_user").strip()
  232. l_pass = st.text_input("Password", type="password", key="l_pass")
  233. if st.button("Login"):
  234. if verify_login(l_user, l_pass):
  235. notifier.send_alert(f"User Login Success: {l_user}")
  236. st.session_state["authenticated_user"] = l_user
  237. st.rerun()
  238. else:
  239. notifier.send_alert(f"User Login Failed: {l_user}")
  240. st.error("Invalid login.")
  241. with tab2:
  242. r_user = st.text_input("Username", key="r_user")
  243. r_email = st.text_input("Email Address", key="r_email")
  244. r_pass = st.text_input("Password", type="password", key="r_pass")
  245. if st.button("Register"):
  246. if len(r_pass) < 6: st.error("Password too short.")
  247. elif register_user(r_user, r_pass, r_email): st.success("Registered safely!")
  248. else: st.error("Username exists.")
  249. with tab3:
  250. f_user = st.text_input("Username", key="f_user")
  251. f_email = st.text_input("Registered Email", key="f_email")
  252. if st.button("Send Reset Link"):
  253. status = reset_password(f_user, f_email)
  254. if status is True:
  255. st.success("Password reset emailed.")
  256. else:
  257. st.error(f"Failed: {status}")
  258. render_version()
  259. if not st.session_state["authenticated_user"]:
  260. st.title("🍔 Food AI Medical Explorer")
  261. st.info("Please login to interrogate the Clinical Data.")
  262. st.stop()
  263. st.title("🍔 Food AI Clinical Explorer")
  264. conn_reader = get_db_connection('app_reader')
  265. tab_chat, tab_explore, tab_plate, tab_planner = st.tabs(["💬 AI Chat", "🔬 Clinical Search", "🍽️ My Plate Builder", "🤖 AI Meal Planner"])
  266. import re
  267. with tab_chat:
  268. c1, c2 = st.columns([4, 1])
  269. c1.subheader("Chat with the Context")
  270. if c2.button("🧹 Clear Chat"):
  271. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  272. st.rerun()
  273. with st.expander("ℹ️ How to use this feature (Examples)"):
  274. st.markdown("""
  275. **Your active conditions (e.g. Pregnant, Diabetic) are automatically sent to the AI in the background. You do not need to type them out.**
  276. *Examples:*
  277. 1. "I am pregnant, diabetic, and have kidney problems. Can I eat sushi?"
  278. 2. "What is a safe snack to stabilize my blood sugar without hurting my kidneys?"
  279. 3. "Can I drink milk? I need calcium for the baby."
  280. 4. "Is it safe to eat a large steak for iron?"
  281. 5. "What foods are strictly forbidden for me?"
  282. """)
  283. if "messages" not in st.session_state:
  284. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  285. st.session_state.messages.append({"role": "user", "content": prompt})
  286. # Display chat history, filtering out TOOL_CALLS
  287. for msg in st.session_state.messages:
  288. if msg["role"] == "tool": continue
  289. display_text = re.sub(r'\[TOOL_CALLS\]\s*\[.*?\]', '', msg["content"]).strip()
  290. if display_text:
  291. st.chat_message(msg["role"]).write(display_text)
  292. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  293. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  294. sys_prompt = f"""You are a helpful medical data analyst AI.
  295. The user has the following health profile / conditions: {profile_text}.
  296. You MUST act as a specialized clinical dietitian. Autonomously deduce what foods are recommended, forbidden, or accepted for these specific conditions and apply these rules to all your answers.
  297. 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.
  298. DO NOT hallucinate that a well-known food like sushi has 0 macros just because the database is missing a row. Use your medical knowledge to supplement missing database data and warn the user of biological facts (e.g. Sushi contains raw fish and carbs from rice)."""
  299. with st.spinner("Analyzing..."):
  300. try:
  301. temp_messages = [{"role": "system", "content": sys_prompt}] + [m for m in st.session_state.messages if m["role"] != "tool"]
  302. response = ollama.chat(model='llama3', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  303. if response.get('message', {}).get('tool_calls'):
  304. for tool in response['message']['tool_calls']:
  305. if tool['function']['name'] == 'local_web_search':
  306. query_arg = tool['function']['arguments'].get('query')
  307. st.info(f"🔍 Web Search triggered for: '{query_arg}'")
  308. search_data = local_web_search(query_arg)
  309. st.session_state.messages.append(response['message'])
  310. st.session_state.messages.append({'role': 'tool', 'content': search_data, 'name': 'local_web_search'})
  311. elif tool['function']['name'] == 'search_nutrition_db':
  312. query_arg = tool['function']['arguments'].get('query')
  313. st.info(f"🗄️ Database Search triggered for: '{query_arg}'")
  314. db_data = search_nutrition_db(query_arg)
  315. st.session_state.messages.append(response['message'])
  316. st.session_state.messages.append({'role': 'tool', 'content': db_data, 'name': 'search_nutrition_db'})
  317. temp_messages = [{"role": "system", "content": sys_prompt}] + st.session_state.messages
  318. response = ollama.chat(model='llama3', messages=temp_messages)
  319. ai_reply = response['message']['content']
  320. ai_reply = re.sub(r'\[TOOL_CALLS\]\s*\[.*?\]', '', ai_reply).strip()
  321. except Exception as e: ai_reply = f"Hold on! Engine execution fault: {e}"
  322. st.session_state.messages.append({"role": "assistant", "content": ai_reply})
  323. st.chat_message("assistant").write(ai_reply)
  324. def highlight_medical_warnings(row):
  325. try:
  326. val = str(row.get('Medical Warning', ''))
  327. if '⚠️' in val: return ['background-color: rgba(255, 0, 0, 0.4); color: white;'] * len(row)
  328. if '💚' in val: return ['background-color: rgba(0, 255, 0, 0.3); color: white;'] * len(row)
  329. except: pass
  330. return [''] * len(row)
  331. with tab_explore:
  332. st.subheader("Clinical Data Search")
  333. with st.expander("ℹ️ How to use this feature (Examples)"):
  334. st.markdown("""
  335. **Your active conditions are automatically flagged (⚠️ or 💚) in the search results.**
  336. *Example Searches:*
  337. 1. `Cereal` *(Checks for high sugar & hidden phosphorus)*
  338. 2. `Cheese` *(Checks for unpasteurized pregnancy risks & high sodium)*
  339. 3. `Fruit Juice` *(Checks for high sugar spikes)*
  340. 4. `Deli Meat` *(Checks for Listeria risk & extreme sodium)*
  341. 5. `White Rice` *(Safe for kidneys but flags high glycemic index)*
  342. """)
  343. sq = st.text_input("Search Product Name or Ingredient")
  344. cols = st.columns(5)
  345. min_pro = cols[0].number_input("Min Protein (g)", 0, 1000, 0)
  346. min_fat = cols[1].number_input("Min Fat (g)", 0, 1000, 0)
  347. min_carb = cols[2].number_input("Min Carbs (g)", 0, 1000, 0)
  348. max_sug = cols[3].number_input("Max Sugar (g)", 0, 1000, 1000)
  349. # Load dynamically fetched limit to prevent Pandas Styler crash
  350. pd.set_option("styler.render.max_elements", 5000000)
  351. opts = [10, 50, 100, 500, 1000]
  352. user_lim_str = get_user_limit(st.session_state["authenticated_user"])
  353. user_lim_val = 1000 if user_lim_str == "All" else int(user_lim_str)
  354. if user_lim_val not in opts: user_lim_val = 50
  355. idx = opts.index(user_lim_val)
  356. limit_rc = cols[4].selectbox("Limit Results", opts, index=idx)
  357. if st.button("Search Database") and sq and conn_reader:
  358. notifier.send_alert(f"Medical DB Search Executed: {sq}")
  359. with st.spinner("Processing massive clinical query..."):
  360. try:
  361. with conn_reader.cursor() as cursor:
  362. l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
  363. query = f"""
  364. SELECT c.code, c.product_name, c.generic_name, c.brands, c.ingredients_text,
  365. a.allergens,
  366. 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,
  367. 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`,
  368. min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
  369. FROM food_db.products_core c
  370. LEFT JOIN food_db.products_allergens a ON c.code = a.code
  371. LEFT JOIN food_db.products_macros m ON c.code = m.code
  372. LEFT JOIN food_db.products_vitamins v ON c.code = v.code
  373. LEFT JOIN food_db.products_minerals min ON c.code = min.code
  374. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN NATURAL LANGUAGE MODE)
  375. AND c.product_name IS NOT NULL AND c.product_name != ''
  376. AND (m.proteins_100g >= %s OR m.proteins_100g IS NULL)
  377. AND (m.fat_100g >= %s OR m.fat_100g IS NULL)
  378. AND (m.carbohydrates_100g >= %s OR m.carbohydrates_100g IS NULL)
  379. AND (m.sugars_100g <= %s OR m.sugars_100g IS NULL)
  380. {l_str}
  381. """
  382. cursor.execute(query, (sq, min_pro, min_fat, min_carb, max_sug))
  383. results = cursor.fetchall()
  384. if results:
  385. # Fetch EAV Medical Profile
  386. eav_profile = get_eav_profile(st.session_state["authenticated_user"])
  387. df = pd.DataFrame(results)
  388. st.markdown("### 🛠️ Dynamic Column Display")
  389. default_columns = [
  390. 'code', 'product_name', 'generic_name', 'brands', 'allergens', 'ingredients_text',
  391. 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'sodium_100g', 'energy-kcal_100g',
  392. 'vitamin-c_100g', 'iron_100g', 'calcium_100g'
  393. ]
  394. all_fetched_cols = list(df.columns)
  395. valid_defaults = [c for c in default_columns if c in all_fetched_cols]
  396. if "selected_columns" not in st.session_state or st.button("Reset Default Columns"):
  397. st.session_state["selected_columns"] = valid_defaults
  398. st.rerun()
  399. chosen_cols = st.multiselect("Customize Dataset View", all_fetched_cols, default=st.session_state["selected_columns"], key="multi_cols")
  400. st.session_state["selected_columns"] = chosen_cols
  401. # Filter dataframe gracefully, but we retain a copy for background analytics
  402. df_display = df[chosen_cols].copy()
  403. warnings_col = []
  404. for idx, row in df.iterrows():
  405. warns = []
  406. ing_text = str(row['ingredients_text']).lower()
  407. all_text = str(row['allergens']).lower()
  408. for param in eav_profile:
  409. cat = param['name'].lower()
  410. val = param['value']
  411. # Disease Analytics
  412. if cat == 'illness':
  413. if val == 'diabetes' and pd.notnull(row.get('sugars_100g')) and float(row['sugars_100g']) > 10.0:
  414. warns.append("⚠️ High Sugar (Diabetes)")
  415. if (val == 'hypertension' or val == 'high bp') and pd.notnull(row.get('sodium_100g')) and float(row['sodium_100g']) > 1.5:
  416. warns.append("⚠️ High Salt (Hypertension)")
  417. if val == 'scurvy' and pd.notnull(row.get('vitamin-c_100g')) and float(row['vitamin-c_100g']) > 0.005:
  418. warns.append("💚 High Vitamin C (Scurvy Recommended)")
  419. if val == 'anemia' and pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  420. warns.append("💚 High Iron (Anemia Recommended)")
  421. # Condition Analytics
  422. if cat == 'condition':
  423. if val == 'pregnant':
  424. if ('cru' in ing_text or 'raw' in ing_text or 'viande crue' in ing_text):
  425. warns.append("⚠️ Raw Foods (Pregnancy Toxoplasmosis)")
  426. if pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  427. warns.append("💚 Med-High Iron (Pregnancy Health)")
  428. if val == 'low fat' and pd.notnull(row.get('fat_100g')) and float(row['fat_100g']) > 20.0:
  429. warns.append("⚠️ High Fat")
  430. if val == 'osteoporosis' and pd.notnull(row.get('calcium_100g')) and float(row['calcium_100g']) > 0.1:
  431. warns.append("💚 High Calcium (Bone Health)")
  432. if eav_data:
  433. ing_text = str(row.get('ingredients_text', '')).lower()
  434. all_text = str(row.get('allergens', '')).lower()
  435. product_name_text = str(row.get('product_name', '')).lower()
  436. for e in eav_data:
  437. cat = str(e['name']).lower()
  438. val = str(e['value']).lower()
  439. # Clinical Trace Checks...
  440. if cat == 'condition' and (val == 'pregnant' or val == 'pregnancy' or val == 'breastfeeding'):
  441. # Forbidden / High Risk (Toxoplasmosis & Listeria)
  442. if any(x in ing_text or x in product_name_text for x in ['cru', 'raw', 'viande crue', 'sushi', 'sashimi', 'poisson cru']):
  443. warns.append("⚠️ Forbidden: Raw Meat/Fish (Toxoplasmosis/Parasite Risk)")
  444. if any(x in ing_text or x in product_name_text for x in ['lait cru', 'unpasteurized', 'non pasteurisé']):
  445. warns.append("⚠️ Forbidden: Unpasteurized Dairy (Listeria Risk)")
  446. if any(x in ing_text or x in product_name_text for x in ['alcool', 'wine', 'alcohol', 'beer']):
  447. warns.append("⚠️ Forbidden: Contains Alcohol")
  448. # Recommended (Iron & Calcium)
  449. if float(row.get('iron_100g', 0) or 0) > 0.003:
  450. warns.append("💚 Recommended: High Iron (Pregnancy Health)")
  451. if float(row.get('calcium_100g', 0) or 0) > 0.120:
  452. warns.append("💚 Recommended: High Calcium (Bone Health / Breastfeeding)")
  453. if cat == 'illness' and val == 'osteoporosis':
  454. if float(row.get('calcium_100g', 0) or 0) < 0.120:
  455. warns.append("⚠️ Low Calcium (Osteoporosis Risk)")
  456. else:
  457. warns.append("💚 Recommended (High Calcium)")
  458. if cat == 'illness' and val == 'scurvy':
  459. if float(row.get('vitamin-c_100g', 0) or 0) < 0.010:
  460. warns.append("⚠️ Low Vitamin C (Scurvy Risk)")
  461. else:
  462. warns.append("💚 Recommended (High Vitamin C)")
  463. if cat == 'diet' and val in ['vegan', 'vegetarian']:
  464. if any(x in ing_text for x in ['meat', 'beef', 'chicken', 'fish', 'gelatin', 'whey', 'pork', 'porc', 'poulet']):
  465. warns.append("⚠️ Contains Animal Products")
  466. if cat == 'diet' and val == 'halal':
  467. if any(x in ing_text for x in ['pork', 'pig', 'porc', 'wine', 'alcohol', 'beer', 'vin']):
  468. warns.append("⚠️ Probable Haram Ingredients (e.g. Pork/Wine)")
  469. if cat in ['dislike', 'allergy']:
  470. if val in ing_text or val in all_text or val in product_name_text:
  471. warns.append(f"⚠️ Contains: {val.upper()}")
  472. warnings_col.append(" | ".join(list(set(warns))) if warns else "✅ Safe for Profile")
  473. df_display.insert(0, 'Medical Warning', warnings_col)
  474. styled_df = df_display.style.apply(highlight_medical_warnings, axis=1)
  475. st.success(f"Analysed {len(results)} records utilizing dynamic Partitions!")
  476. st.dataframe(styled_df, use_container_width=True)
  477. if st.button("🤖 Ask AI to Evaluate This Table"):
  478. with st.spinner("AI is dynamically evaluating these records against your profile..."):
  479. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  480. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  481. eval_prompt = f"The user has this profile: {profile_text}. Evaluate these foods and state which are highly recommended or strictly forbidden: {df_display.to_dict('records')}"
  482. try:
  483. response = ollama.chat(model='llama3', messages=[{'role': 'user', 'content': eval_prompt}])
  484. st.info(response['message']['content'])
  485. except Exception as e:
  486. st.error(f"AI Evaluation Failed: {e}")
  487. else:
  488. st.warning("No products found matching those strict terms.")
  489. except Exception as e: st.error(f"SQL/Pandas Error: {e}")
  490. with tab_plate:
  491. st.subheader("🍽️ My Plate Builder")
  492. with st.expander("ℹ️ How to use this feature (Examples & Logic)"):
  493. st.markdown("""
  494. **Plate Builder Logic:**
  495. 1. Create a New Plate.
  496. 2. Search for exact food words (e.g. 'chicken', 'egg').
  497. 3. Add the food with a specific portion (e.g. '150g').
  498. 4. The system calculates the combined macros.
  499. 5. Use the 🗑️ buttons to delete incorrect items or entire plates.
  500. *Example Plates:*
  501. 1. `150g White Rice` + `50g Chicken Breast` + `100g Green Beans`
  502. 2. `200g Potatoes` + `100g Tomatoes` + `100g Beef`
  503. 3. `100g Spinach Salad` + `50g Feta Cheese`
  504. 4. `200g Lentils` + `100g Quinoa`
  505. 5. `100g Apple` + `30g Almonds`
  506. """)
  507. uid = get_user_id(st.session_state["authenticated_user"])
  508. conn = get_db_connection('app_auth')
  509. if conn and uid:
  510. with conn.cursor() as cursor:
  511. cursor.execute("SELECT id, plate_name FROM plates WHERE user_id = %s", (uid,))
  512. plates = cursor.fetchall()
  513. with st.expander("➕ Create a New Plate"):
  514. new_plate_name = st.text_input("Plate Name")
  515. if st.button("Create Plate"):
  516. cursor.execute("INSERT INTO plates (user_id, plate_name) VALUES (%s, %s)", (uid, new_plate_name))
  517. conn.commit()
  518. st.session_state["active_plate"] = new_plate_name
  519. st.rerun()
  520. if plates:
  521. colA, colB = st.columns([4, 1])
  522. plate_names = [p['plate_name'] for p in plates]
  523. default_idx = plate_names.index(st.session_state["active_plate"]) if "active_plate" in st.session_state and st.session_state["active_plate"] in plate_names else 0
  524. selected_plate = colA.selectbox("Select Active Plate", plate_names, index=default_idx)
  525. st.session_state["active_plate"] = selected_plate
  526. active_p_id = next(p['id'] for p in plates if p['plate_name'] == selected_plate)
  527. if colB.button("🗑️ Delete Plate"):
  528. cursor.execute("DELETE FROM plates WHERE id = %s", (active_p_id,))
  529. conn.commit()
  530. if "active_plate" in st.session_state: del st.session_state["active_plate"]
  531. st.rerun()
  532. cursor.execute("""
  533. SELECT i.id, i.product_code, i.quantity_grams, p.product_name, p.proteins_100g, p.fat_100g, p.carbohydrates_100g
  534. FROM plate_items i LEFT JOIN products p ON i.product_code = p.code WHERE i.plate_id = %s
  535. """, (active_p_id,))
  536. items = cursor.fetchall()
  537. if items:
  538. for i in items:
  539. c1, c2 = st.columns([5, 1])
  540. c1.markdown(f"<li><b>{i['quantity_grams']}g</b> of {i['product_name']} (Pro: {i['proteins_100g'] or 0}g)</li>", unsafe_allow_html=True)
  541. if c2.button("🗑️", key=f"del_item_{i['id']}"):
  542. cursor.execute("DELETE FROM plate_items WHERE id = %s", (i['id'],))
  543. conn.commit()
  544. st.rerun()
  545. total_pro = sum((float(i['proteins_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  546. total_fat = sum((float(i['fat_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  547. total_carb = sum((float(i['carbohydrates_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items)
  548. st.info(f"**Total Protein:** {total_pro:.1f}g | **Total Fat:** {total_fat:.1f}g | **Total Carbs:** {total_carb:.1f}g")
  549. st.markdown("---")
  550. st.markdown("#### ➕ Add Food to Plate")
  551. add_search = st.text_input("Search Exact Product Name (e.g. 'chicken', 'egg')")
  552. if add_search:
  553. bool_search = " ".join([f"+{w}" for w in add_search.split()])
  554. cursor.execute("""
  555. SELECT c.code, c.product_name
  556. FROM food_db.products_core c
  557. JOIN food_db.products_macros m ON c.code = m.code
  558. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN BOOLEAN MODE)
  559. AND c.product_name IS NOT NULL AND c.product_name != ''
  560. AND m.proteins_100g IS NOT NULL AND m.fat_100g IS NOT NULL AND m.carbohydrates_100g IS NOT NULL
  561. LIMIT 10
  562. """, (bool_search,))
  563. search_res = cursor.fetchall()
  564. if search_res:
  565. options = {f"{r['product_name']} ({r['code']})": r for r in search_res}
  566. selected_str = st.selectbox("Select Product", list(options.keys()))
  567. selected_product = options[selected_str]
  568. add_amount_str = st.text_input("Portion Quantity (e.g., '100g', '2 tbsp', '1.5 cups', '1 pinch')", value="100g")
  569. if st.button("Add Item to Plate"):
  570. # Use UnitConverter to parse
  571. grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
  572. if grams is not None:
  573. cursor.execute("INSERT INTO plate_items (plate_id, product_code, quantity_grams) VALUES (%s, %s, %s)",
  574. (active_p_id, selected_product['code'], grams))
  575. conn.commit()
  576. st.success(f"Added {grams}g of {selected_product['product_name']}!")
  577. st.rerun()
  578. else:
  579. st.error("Could not parse unit. Please use format like '100g' or '1 cup'.")
  580. else:
  581. st.warning("No products found.")
  582. with tab_planner:
  583. st.subheader("🤖 AI Meal Planner")
  584. with st.expander("ℹ️ How to use this feature (Examples)"):
  585. st.markdown("""
  586. **Your active conditions are automatically applied to the generated menu.**
  587. *Example Prompts:*
  588. 1. "Generate a full day meal plan for me. I am pregnant, diabetic, and have kidney disease."
  589. 2. "Plan a pregnancy-safe dinner that won't spike my blood sugar."
  590. 3. "I need a high-iron lunch that is safe for my kidneys."
  591. 4. "Plan a breakfast without dairy that is kidney-friendly."
  592. 5. "Give me a 3-day meal prep plan ensuring no raw fish, controlled protein portions, and steady complex carbs."
  593. """)
  594. p_col1, p_col2, p_col3 = st.columns(3)
  595. target_cal = p_col1.number_input("Target Daily Calories (kcal)", 1000, 5000, 2000, 50)
  596. diet_pref = p_col2.selectbox("Dietary Preference", ["Omnivore", "Vegetarian", "Vegan", "Keto", "Paleo"])
  597. meal_count = p_col3.slider("Number of Meals", 2, 6, 3)
  598. extra_notes = st.text_input("Any additional allergies or goals?")
  599. if st.button("Generate Professional Menu"):
  600. with st.spinner("AI is formulating and interrogating the local database..."):
  601. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  602. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  603. sys_prompt = f"""You are a professional clinical Dietitian planner. Target: {target_cal}kcal over {meal_count} meals.
  604. Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
  605. The user has the following health profile / conditions: {profile_text}.
  606. You MUST autonomously deduce what foods are recommended, forbidden, or accepted for these specific conditions and ensure the menu perfectly respects their medical requirements!
  607. CRITICAL INSTRUCTIONS:
  608. - YOU MUST USE the `search_nutrition_db` tool to find real products and their exact macros before constructing the menu!
  609. - ALWAYS output exactly as a JSON array of objects. DO NOT OUTPUT MARKDOWN. DO NOT OUTPUT ANY TEXT EXCEPT JSON.
  610. - JSON Format required:
  611. [
  612. {{"meal": "Breakfast", "food": "100g Oatmeal with 50g berries", "calories": 300, "salt_mg": 10, "fat_g": 5, "iron_mg": 2}}
  613. ]
  614. - Ensure the total calories sum up closely to {target_cal}.
  615. """
  616. temp_messages = [{'role': 'system', 'content': sys_prompt}, {'role': 'user', 'content': 'Generate my meal plan. Find real foods from the DB.'}]
  617. response = ollama.chat(model='llama3', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  618. # Simple loop to handle multiple tool calls (up to 3 times to prevent infinite loops)
  619. for _ in range(3):
  620. if response.get('message', {}).get('tool_calls'):
  621. temp_messages.append(response['message'])
  622. for tool in response['message']['tool_calls']:
  623. if tool['function']['name'] == 'local_web_search':
  624. query_arg = tool['function']['arguments'].get('query')
  625. st.info(f"🔍 Planner Web Search triggered for: '{query_arg}'")
  626. search_data = local_web_search(query_arg)
  627. temp_messages.append({'role': 'tool', 'content': search_data, 'name': 'local_web_search'})
  628. elif tool['function']['name'] == 'search_nutrition_db':
  629. query_arg = tool['function']['arguments'].get('query')
  630. st.info(f"🗄️ Planner DB Search triggered for: '{query_arg}'")
  631. db_data = search_nutrition_db(query_arg)
  632. temp_messages.append({'role': 'tool', 'content': db_data, 'name': 'search_nutrition_db'})
  633. response = ollama.chat(model='llama3', messages=temp_messages, tools=[search_tool_schema, db_search_tool_schema])
  634. else:
  635. break
  636. import json
  637. raw_text = response['message']['content']
  638. raw_text = re.sub(r'\[TOOL_CALLS\]\s*\[.*?\]', '', raw_text).strip()
  639. try:
  640. start_idx = raw_text.find('[')
  641. end_idx = raw_text.rfind(']') + 1
  642. if start_idx != -1 and end_idx != -1:
  643. json_data = json.loads(raw_text[start_idx:end_idx])
  644. df_plan = pd.DataFrame(json_data)
  645. total_cals = df_plan['calories'].sum() if 'calories' in df_plan else 0
  646. total_salt = df_plan['salt_mg'].sum() if 'salt_mg' in df_plan else 0
  647. total_fat = df_plan['fat_g'].sum() if 'fat_g' in df_plan else 0
  648. total_iron = df_plan['iron_mg'].sum() if 'iron_mg' in df_plan else 0
  649. total_row = pd.DataFrame([{"meal": "TOTAL", "food": "---", "calories": total_cals, "salt_mg": total_salt, "fat_g": total_fat, "iron_mg": total_iron}])
  650. df_plan = pd.concat([df_plan, total_row], ignore_index=True)
  651. st.dataframe(df_plan, use_container_width=True)
  652. if abs(total_cals - target_cal) > 200:
  653. st.warning(f"Note: Total calories ({total_cals}) differ from your target ({target_cal}).")
  654. else:
  655. st.error("AI failed to output valid JSON. Raw output:")
  656. st.text(raw_text)
  657. except Exception as e:
  658. st.error(f"Failed to parse AI output: {e}")
  659. st.text(raw_text)
  660. if conn_reader: conn_reader.close()