app.py 92 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687
  1. #ident "@(#)$Format:LocalFoodAI_lanfr144:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  2. import streamlit as st
  3. import extra_streamlit_components as stx
  4. import subprocess
  5. import pymysql
  6. import bcrypt
  7. import random
  8. import string
  9. import time
  10. import os
  11. import pandas as pd
  12. import html
  13. from snmp_notifier import notifier
  14. from unit_converter import UnitConverter
  15. from fpdf import FPDF
  16. import myloginpath
  17. import ollama
  18. import requests
  19. import smtplib
  20. from email.message import EmailMessage
  21. from typing import Optional, List, Dict, Any, Tuple
  22. import threading
  23. import os
  24. def get_active_model() -> str:
  25. try:
  26. from dotenv import load_dotenv
  27. current_dir = os.path.dirname(os.path.abspath(__file__))
  28. env_path = os.path.join(current_dir, '.env')
  29. load_dotenv(dotenv_path=env_path, override=True)
  30. except Exception:
  31. pass
  32. return os.environ.get('LLM_MODEL', 'llama3.2:3b')
  33. ACTIVE_MODEL = get_active_model()
  34. def strip_scratchpad(text: str) -> str:
  35. import re
  36. # Strip out the XML <scratchpad> tag and everything in between, non-greedily
  37. clean_text = re.sub(r'<scratchpad>.*?</scratchpad>', '', text, flags=re.DOTALL)
  38. return clean_text.strip()
  39. def extract_allergens_from_json(data) -> list:
  40. table_data = []
  41. def add_item(aliment, allergens):
  42. if not aliment or not allergens:
  43. return
  44. if isinstance(allergens, list):
  45. cleaned = [str(alg).strip().title() for alg in allergens if alg]
  46. if cleaned:
  47. table_data.append({
  48. "Aliment (Ingredient)": str(aliment).strip().title(),
  49. "Allergen Kind(s)": ", ".join(cleaned)
  50. })
  51. elif isinstance(allergens, str) and allergens.strip():
  52. table_data.append({
  53. "Aliment (Ingredient)": str(aliment).strip().title(),
  54. "Allergen Kind(s)": allergens.strip().title()
  55. })
  56. if isinstance(data, list):
  57. for entry in data:
  58. if isinstance(entry, dict):
  59. aliment = entry.get('aliment') or entry.get('name') or entry.get('food')
  60. allergens = entry.get('allergen') or entry.get('allergens') or entry.get('kinds') or []
  61. if aliment and allergens:
  62. add_item(aliment, allergens)
  63. else:
  64. for k, v in entry.items():
  65. if isinstance(v, (list, str)):
  66. add_item(k, v)
  67. elif isinstance(entry, list) and len(entry) >= 2:
  68. add_item(entry[0], entry[1])
  69. elif isinstance(data, dict):
  70. al_list = data.get('aliment') or data.get('aliments')
  71. all_list = data.get('allergen') or data.get('allergens') or data.get('kinds')
  72. if isinstance(al_list, list) and isinstance(all_list, list):
  73. for item in all_list:
  74. if isinstance(item, dict):
  75. name = item.get('name') or item.get('aliment')
  76. types = item.get('types') or item.get('type') or item.get('kinds') or item.get('allergen') or item.get('allergens')
  77. if name and types:
  78. add_item(name, types)
  79. elif isinstance(item, list) and len(item) >= 2:
  80. add_item(item[0], item[1])
  81. if not table_data and len(al_list) == len(all_list):
  82. for a, alg in zip(al_list, all_list):
  83. add_item(a, alg)
  84. if not table_data:
  85. for key, val in data.items():
  86. if isinstance(val, list):
  87. for entry in val:
  88. if isinstance(entry, dict):
  89. aliment = entry.get('aliment') or entry.get('name') or entry.get('food')
  90. allergens = entry.get('allergen') or entry.get('allergens') or entry.get('kinds') or []
  91. if aliment and allergens:
  92. add_item(aliment, allergens)
  93. else:
  94. for k, v in entry.items():
  95. if isinstance(v, (list, str)):
  96. add_item(k, v)
  97. elif isinstance(entry, list) and len(entry) >= 2:
  98. add_item(entry[0], entry[1])
  99. elif isinstance(entry, str):
  100. if key not in ['aliment', 'aliments', 'allergen', 'allergens', 'kinds', 'types']:
  101. add_item(key, val)
  102. break
  103. elif isinstance(val, dict):
  104. for k, v in val.items():
  105. if isinstance(v, (list, str)):
  106. add_item(k, v)
  107. elif isinstance(v, dict):
  108. add_item(k, v.get('allergen') or v.get('allergens') or [])
  109. elif isinstance(val, str):
  110. if key not in ['aliment', 'aliments', 'allergen', 'allergens', 'kinds', 'types']:
  111. add_item(key, val)
  112. if not table_data:
  113. for k, v in data.items():
  114. if k not in ['aliment', 'aliments', 'allergen', 'allergens', 'kinds', 'types']:
  115. add_item(k, v)
  116. return table_data
  117. @st.cache_data(show_spinner=False)
  118. def query_plate_allergens(unique_aliments: list) -> list:
  119. import ollama
  120. import json
  121. table_data = []
  122. if not unique_aliments:
  123. return table_data
  124. aliments_str = ", ".join(unique_aliments)
  125. prompt = f"In these aliments : {aliments_str} are there allergens and if it is the case also said the allergen kinds. Return the answer as json array with two variables aliment and the associate allergen in an array find inside it. focus on the list, do not add or delete aliments."
  126. try:
  127. response = ollama.chat(
  128. model=get_active_model(),
  129. messages=[{'role': 'user', 'content': prompt}],
  130. format='json'
  131. )
  132. res_content = response['message']['content'].strip()
  133. data = json.loads(res_content)
  134. table_data = extract_allergens_from_json(data)
  135. except Exception as e:
  136. notifier.send_alert(f"LLM query_plate_allergens error: {e}")
  137. pass
  138. return table_data
  139. @st.cache_data(show_spinner=False)
  140. def detect_allergens_from_text(name: str, ingredients: str) -> set:
  141. import re
  142. import ollama
  143. import json
  144. detected = set()
  145. # Extract candidate terms from name and ingredients
  146. candidates = []
  147. if ingredients:
  148. parts = re.split(r'[,;()\[\]\n\r]', ingredients)
  149. for p in parts:
  150. p_clean = re.sub(r'[*_\d%]+', '', p).strip()
  151. if len(p_clean) > 2 and p_clean.lower() not in ['ingredients', 'and', 'contains', 'may contain', 'natural', 'artificial', 'flavors', 'flavor', 'preservative', 'color', 'colors']:
  152. candidates.append(p_clean)
  153. if name:
  154. name_clean = re.sub(r'[*_\d%]+', '', name).strip()
  155. if len(name_clean) > 2:
  156. candidates.append(name_clean)
  157. for word in re.split(r'\s+', name_clean):
  158. w_clean = word.strip()
  159. if len(w_clean) > 2 and w_clean.lower() not in ['with', 'and', 'for', 'the', 'bar', 'cup', 'can', 'bag', 'mix']:
  160. candidates.append(w_clean)
  161. # Deduplicate candidates while keeping order
  162. seen = set()
  163. unique_candidates = []
  164. for c in candidates:
  165. c_low = c.lower()
  166. if c_low not in seen:
  167. seen.add(c_low)
  168. unique_candidates.append(c)
  169. if not unique_candidates:
  170. return detected
  171. # Ask the LLM using the JSON array prompt format
  172. aliments_str = ", ".join(unique_candidates)
  173. prompt = f"In these aliments : {aliments_str} are there allergens and if it is the case also said the allergen kinds. Return the answer as json array with two variables aliment and the associate allergen in an array find inside it. focus on the list, do not add or delete aliments."
  174. try:
  175. response = ollama.chat(
  176. model=get_active_model(),
  177. messages=[{'role': 'user', 'content': prompt}],
  178. format='json'
  179. )
  180. res_content = response['message']['content'].strip()
  181. data = json.loads(res_content)
  182. parsed = extract_allergens_from_json(data)
  183. for item in parsed:
  184. name = item.get("Aliment (Ingredient)")
  185. if name:
  186. detected.add(name)
  187. except Exception as e:
  188. notifier.send_alert(f"LLM detect_allergens_from_text error: {e}")
  189. pass
  190. return detected
  191. def filter_scratchpad_stream(stream, raw_accumulator=None):
  192. buffer = ""
  193. in_scratchpad = False
  194. for chunk in stream:
  195. content = chunk['message']['content']
  196. if raw_accumulator is not None:
  197. raw_accumulator.append(content)
  198. buffer += content
  199. while True:
  200. if not in_scratchpad:
  201. start_idx = buffer.find("<scratchpad>")
  202. if start_idx != -1:
  203. if start_idx > 0:
  204. yield buffer[:start_idx]
  205. yield "\n\n> 💭 **AI Thinking Process:**\n> "
  206. buffer = buffer[start_idx + 12:]
  207. in_scratchpad = True
  208. else:
  209. yield_len = len(buffer) - 11
  210. if yield_len > 0:
  211. yield buffer[:yield_len]
  212. buffer = buffer[yield_len:]
  213. break
  214. else:
  215. end_idx = buffer.find("</scratchpad>")
  216. if end_idx != -1:
  217. scratch_content = buffer[:end_idx]
  218. scratch_content_formatted = scratch_content.replace("\n", "\n> ")
  219. yield scratch_content_formatted
  220. yield "\n\n"
  221. buffer = buffer[end_idx + 13:]
  222. in_scratchpad = False
  223. else:
  224. yield_len = len(buffer) - 12
  225. if yield_len > 0:
  226. scratch_content = buffer[:yield_len]
  227. scratch_content_formatted = scratch_content.replace("\n", "\n> ")
  228. yield scratch_content_formatted
  229. buffer = buffer[yield_len:]
  230. break
  231. if buffer:
  232. if in_scratchpad:
  233. yield buffer.replace("\n", "\n> ")
  234. else:
  235. yield buffer
  236. def pull_model_bg():
  237. try: ollama.pull(get_active_model())
  238. except: pass
  239. threading.Thread(target=pull_model_bg, daemon=True).start()
  240. def local_web_search(query: str) -> str:
  241. try:
  242. req = requests.get(f'http://127.0.0.1:8080/search', params={'q': query, 'format': 'json'})
  243. if req.status_code == 200:
  244. data = req.json()
  245. results = data.get('results', [])
  246. if not results: return f"No results found on the web for '{query}'."
  247. snippets = [f"Source: {r.get('url')}\nContent: {r.get('content')}" for r in results[:3]]
  248. return "\n\n".join(snippets)
  249. return "Search engine returned an error."
  250. except Exception as e: return f"Local search engine unreachable: {e}"
  251. search_tool_schema = {
  252. 'type': 'function',
  253. 'function': {
  254. 'name': 'local_web_search',
  255. 'description': 'Search the internet for info not in DB.',
  256. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string'}}, 'required': ['query']},
  257. },
  258. }
  259. def search_nutrition_db(query: str, user_eav=None) -> str:
  260. conn = get_db_connection('app_reader')
  261. if not conn: return "Database connection failed."
  262. try:
  263. with conn.cursor() as cursor:
  264. # Dynamically build strictly-enforced clinical SQL filters
  265. clinical_filters = ""
  266. if user_eav:
  267. for p in user_eav:
  268. name = p['name'].lower()
  269. val = p['value'].lower()
  270. if name in ['condition', 'illness']:
  271. if val == 'diabetes': clinical_filters += " AND m.sugars_100g < 5.0"
  272. elif 'kidney' in val: clinical_filters += " AND m.proteins_100g < 15.0"
  273. elif 'hypertension' in val: clinical_filters += " AND m.sodium_100g < 0.2"
  274. elif name in ['diet', 'religious', 'preference']:
  275. if val == 'kosher': clinical_filters += " AND c.ingredients_text NOT LIKE '%pork%' AND c.ingredients_text NOT LIKE '%shellfish%'"
  276. elif val == 'halal': clinical_filters += " AND c.ingredients_text NOT LIKE '%pork%' AND c.ingredients_text NOT LIKE '%wine%' AND c.ingredients_text NOT LIKE '%alcohol%'"
  277. elif val in ['christian', 'good friday', 'ash wednesday']: clinical_filters += " AND c.ingredients_text NOT LIKE '%meat%' AND c.ingredients_text NOT LIKE '%beef%' AND c.ingredients_text NOT LIKE '%chicken%' AND c.ingredients_text NOT LIKE '%pork%'"
  278. sql = f"""
  279. SELECT c.code, c.product_name, m.proteins_100g, m.fat_100g, m.carbohydrates_100g, m.sugars_100g
  280. FROM food_db.products_core c
  281. LEFT JOIN food_db.products_macros m ON c.code = m.code
  282. WHERE MATCH(c.product_name, c.ingredients_text) AGAINST(%s IN BOOLEAN MODE)
  283. AND c.product_name IS NOT NULL AND c.product_name != '' AND c.product_name != 'None'
  284. {clinical_filters}
  285. """
  286. bool_query = " ".join([f"+{w}" for w in query.split()])
  287. cursor.execute(sql, (bool_query,))
  288. results = cursor.fetchall()
  289. if not results: return f"No database records found for '{query}'."
  290. snippets = []
  291. for r in results:
  292. pro = float(r['proteins_100g'] or 0)
  293. fat = float(r['fat_100g'] or 0)
  294. carb = float(r['carbohydrates_100g'] or 0)
  295. sug = float(r['sugars_100g'] or 0)
  296. snippets.append(f"- {r['product_name']}: Protein {pro:.2f}g, Fat {fat:.2f}g, Carbs {carb:.2f}g, Sugars {sug:.2f}g (per 100g)")
  297. return "\n".join(snippets)
  298. except Exception as e:
  299. return f"Database query failed: {e}"
  300. finally:
  301. conn.close()
  302. def query_sql_readonly(sql: str) -> str:
  303. """Executes a read-only SELECT query against the food_db MySQL database."""
  304. sql_clean = sql.strip().strip(";").strip()
  305. if not any(sql_clean.upper().startswith(kw) for kw in ["SELECT", "SHOW", "DESCRIBE", "EXPLAIN"]):
  306. return "Error: Only read-only queries (SELECT, SHOW, DESCRIBE, EXPLAIN) are permitted."
  307. conn = get_db_connection('app_reader')
  308. if not conn:
  309. return "Error: Database connection failed."
  310. try:
  311. with conn.cursor() as cursor:
  312. cursor.execute(sql)
  313. results = cursor.fetchall()
  314. if not results:
  315. return "Query returned 0 rows."
  316. # Form custom markdown table
  317. headers = list(results[0].keys())
  318. lines = []
  319. lines.append("| " + " | ".join(headers) + " |")
  320. lines.append("| " + " | ".join(["---"] * len(headers)) + " |")
  321. # Cap at 10 results to stay within model context bounds
  322. row_limit = 10
  323. for row in results[:row_limit]:
  324. vals = [str(row[h]).replace("\n", " ").replace("|", "\\|") for h in headers]
  325. lines.append("| " + " | ".join(vals) + " |")
  326. truncated = f"\n*(Showing first {row_limit} of {len(results)} results)*" if len(results) > row_limit else ""
  327. return "\n".join(lines) + truncated
  328. except Exception as e:
  329. return f"Database query error: {str(e)}"
  330. finally:
  331. conn.close()
  332. def query_web_searxng(query: str) -> str:
  333. """Performs an anonymous local web search using the SearXNG search engine."""
  334. try:
  335. searxng_url = os.environ.get("SEARXNG_HOST", "http://searxng:8080")
  336. resp = requests.get(f"{searxng_url}/search", params={'q': query, 'format': 'json'}, timeout=5)
  337. if resp.status_code == 200:
  338. results = resp.json().get('results', [])
  339. if results:
  340. snippets = []
  341. for r in results[:4]:
  342. snippets.append(f"- **{r.get('title')}**: {r.get('content')} (Source: {r.get('url')})")
  343. return "\n".join(snippets)
  344. return "No web results found."
  345. except Exception as e:
  346. return f"Web search tool error: {str(e)}"
  347. db_search_tool_schema = {
  348. 'type': 'function',
  349. 'function': {
  350. 'name': 'search_nutrition_db',
  351. 'description': 'Search the local medical nutrition database for product macros and ingredients. ALWAYS prioritize this over web search.',
  352. 'parameters': {'type': 'object', 'properties': {'query': {'type': 'string', 'description': 'The product or food name to search for (e.g. apple, chicken, bread)'}}, 'required': ['query']},
  353. },
  354. }
  355. def get_db_connection(login_path):
  356. try:
  357. import os
  358. db_host = os.environ.get('DB_HOST')
  359. # Check if environment variables exist for this login path
  360. db_user = os.environ.get(f'{login_path.upper()}_USER') or os.environ.get('DB_USER')
  361. db_pass = os.environ.get(f'{login_path.upper()}_PASS') or os.environ.get('DB_PASS')
  362. if db_host and db_user and db_pass:
  363. return pymysql.connect(
  364. host=db_host,
  365. user=db_user,
  366. password=db_pass,
  367. database='food_db',
  368. cursorclass=pymysql.cursors.DictCursor
  369. )
  370. conf = myloginpath.parse(login_path)
  371. if not conf or not conf.get('user'):
  372. 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.")
  373. notifier.send_alert(f"MySQL Config Missing: {login_path}")
  374. return None
  375. return pymysql.connect(
  376. host=conf.get('host', '127.0.0.1'),
  377. user=conf.get('user'),
  378. password=conf.get('password'),
  379. database='food_db',
  380. cursorclass=pymysql.cursors.DictCursor
  381. )
  382. except Exception as e:
  383. st.error(f"Connection Failed: {e}")
  384. notifier.send_alert(f"Database Connection Failed ({login_path}): {e}")
  385. return None
  386. from contextlib import contextmanager
  387. @contextmanager
  388. def db_cursor(login_path: str):
  389. conn = get_db_connection(login_path)
  390. if not conn:
  391. yield None
  392. return
  393. try:
  394. with conn.cursor() as cursor:
  395. yield cursor
  396. conn.commit()
  397. except Exception as e:
  398. conn.rollback()
  399. st.error(f"Database query error: {e}")
  400. notifier.send_alert(f"Database Query Error ({login_path}): {e}")
  401. raise e
  402. finally:
  403. conn.close()
  404. def verify_login(username: str, password: str) -> bool:
  405. with db_cursor('app_auth') as cursor:
  406. if not cursor: return False
  407. cursor.execute("SELECT password_hash FROM users WHERE username = %s", (username,))
  408. result = cursor.fetchone()
  409. if result: return bcrypt.checkpw(password.encode('utf-8'), result['password_hash'].encode('utf-8'))
  410. return False
  411. def get_user_id(username: str) -> Optional[int]:
  412. with db_cursor('app_auth') as cursor:
  413. if not cursor: return None
  414. cursor.execute("SELECT id FROM users WHERE username = %s", (username,))
  415. result = cursor.fetchone()
  416. return result['id'] if result else None
  417. def get_eav_profile(username: str) -> List[Dict[str, Any]]:
  418. uid = get_user_id(username)
  419. if not uid: return []
  420. with db_cursor('app_auth') as cursor:
  421. if not cursor: return []
  422. 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,))
  423. return cursor.fetchall()
  424. def get_user_limit(username: str) -> str:
  425. with db_cursor('app_auth') as cursor:
  426. if not cursor: return "50"
  427. cursor.execute("SELECT search_limit FROM users WHERE username = %s", (username,))
  428. result = cursor.fetchone()
  429. return result['search_limit'] if (result and result['search_limit']) else "50"
  430. def register_user(username: str, password: str, email: str) -> bool:
  431. hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  432. try:
  433. with db_cursor('app_auth') as cursor:
  434. if not cursor: return False
  435. cursor.execute("INSERT INTO users (username, password_hash, email) VALUES (%s, %s, %s)", (username, hashed, email))
  436. send_email(email, "Welcome to Local Food AI", f"Hello {username}, your account was securely created!", to_name=username.title())
  437. return True
  438. except pymysql.err.IntegrityError:
  439. return False
  440. def send_email(to_email: str, subject: str, body: str, to_name: str = "User") -> Any:
  441. msg = EmailMessage()
  442. msg.set_content(body)
  443. msg['Subject'] = subject
  444. msg['From'] = '"Clinical Food AI System" <security@localfoodai.com>'
  445. msg['To'] = f'"{to_name}" <{to_email}>'
  446. for attempt in range(5):
  447. try:
  448. s = smtplib.SMTP('localhost', 25)
  449. s.send_message(msg)
  450. s.quit()
  451. return True
  452. except Exception as e:
  453. if attempt == 4:
  454. return f"SMTP Delivery Failed: {str(e)}"
  455. time.sleep(2)
  456. return "Unknown Error Occurred"
  457. def reset_password(username: str, email: str) -> Any:
  458. with db_cursor('app_auth') as cursor:
  459. if not cursor: return False
  460. cursor.execute("SELECT id, email FROM users WHERE username = %s", (username,))
  461. user = cursor.fetchone()
  462. if user and user['email'] == email:
  463. new_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
  464. hashed = bcrypt.hashpw(new_pass.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
  465. cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (hashed, user['id']))
  466. status = send_email(email, "Password Reset", f"Your new temporary password is: {new_pass}", to_name=username.title())
  467. return True if status is True else status
  468. return False
  469. # UI Theming
  470. def is_valid_image_url(url):
  471. if not url or not isinstance(url, str):
  472. return False
  473. url = url.strip()
  474. if not url.startswith(('http://', 'https://')):
  475. return False
  476. if 'invalid' in url.lower():
  477. return False
  478. return True
  479. def reformat_git_date(date_str):
  480. from datetime import datetime
  481. try:
  482. import email.utils
  483. dt = email.utils.parsedate_to_datetime(date_str)
  484. return dt.strftime("%Y/%m/%d %H:%M:%S")
  485. except Exception:
  486. try:
  487. dt = datetime.strptime(date_str.strip(), "%a %b %d %H:%M:%S %Y %z")
  488. return dt.strftime("%Y/%m/%d %H:%M:%S")
  489. except Exception:
  490. return date_str
  491. def render_version():
  492. st.markdown("---")
  493. git_version = None
  494. git_hash = None
  495. # 1. Parse from the smudged ident header in app.py
  496. try:
  497. current_dir = os.path.dirname(os.path.abspath(__file__))
  498. app_path = os.path.join(current_dir, 'app.py')
  499. if os.path.exists(app_path):
  500. with open(app_path, 'r', encoding='utf-8') as f:
  501. import re
  502. for _ in range(15):
  503. line = f.readline()
  504. if not line:
  505. break
  506. if "$Form" + "at:" in line:
  507. match = re.search(r'\$For' + r'mat:(.*?)\$', line)
  508. if match:
  509. parts = match.group(1).split(':')
  510. if len(parts) >= 13 and not parts[2].startswith('%an'):
  511. git_version = f"{parts[9]}:{parts[10]}:{parts[11]}"
  512. h = parts[12]
  513. git_hash = f"{h[:2]}{h[-2:]}" if h else ""
  514. break
  515. except Exception:
  516. pass
  517. # 2. Fallback using git log command
  518. if not git_version or not git_hash:
  519. try:
  520. git_version = subprocess.check_output(
  521. ['git', 'log', '-1', '--date=format:%Y/%m/%d %H:%M:%S', '--format=%cd', 'app.py'],
  522. stderr=subprocess.DEVNULL
  523. ).decode('utf-8').strip()
  524. git_hash_full = subprocess.check_output(
  525. ['git', 'log', '-1', '--format=%H', 'app.py'],
  526. stderr=subprocess.DEVNULL
  527. ).decode('utf-8').strip()
  528. if git_hash_full:
  529. git_hash = f"{git_hash_full[:2]}{git_hash_full[-2:]}"
  530. except Exception:
  531. pass
  532. st.caption(f"🚀 Version: {git_version}")
  533. st.caption(f"📅 Git ID: {git_version} {git_hash}")
  534. st.caption(f"Model: {get_active_model()}")
  535. st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
  536. cookie_manager = stx.CookieManager(key="cookie_manager")
  537. # Wait for cookies to load
  538. cookies = cookie_manager.get_all()
  539. if cookies is None:
  540. st.stop()
  541. # If the cookie has auth_user, set/restore session state
  542. cookie_user = cookie_manager.get(cookie="auth_user")
  543. if st.session_state.get("logged_out"):
  544. st.session_state["authenticated_user"] = None
  545. if not cookie_user:
  546. st.session_state["logged_out"] = False
  547. else:
  548. if cookie_user:
  549. st.session_state["authenticated_user"] = cookie_user
  550. elif "authenticated_user" not in st.session_state:
  551. st.session_state["authenticated_user"] = None
  552. st.markdown("""
  553. <style>
  554. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
  555. html, body, [class*="css"] { font-family: 'Inter', sans-serif; background-color: #0b192c; color: #e2e8f0; }
  556. h1, h2, h3 { color: #38bdf8 !important; font-weight: 600; letter-spacing: 0.5px; }
  557. div[data-testid="stSidebar"] { background: rgba(11, 25, 44, 0.95) !important; backdrop-filter: blur(10px); border-right: 1px solid #1e293b; }
  558. .stButton>button { background: linear-gradient(135deg, #0ea5e9, #0284c7); color: white; border: none; border-radius: 6px; }
  559. .stButton>button:hover { transform: scale(1.02); }
  560. .stTextInput>div>div>input, .stNumberInput>div>div>input, .stSelectbox>div>div>div { background-color: #0f172a; color: #f8fafc; border: 1px solid #38bdf8; caret-color: #f8fafc !important; }
  561. </style>
  562. """, unsafe_allow_html=True)
  563. if "authenticated_user" not in st.session_state:
  564. st.session_state["authenticated_user"] = None
  565. with st.sidebar:
  566. st.title("User Portal 🔐")
  567. render_version()
  568. with st.expander("ℹ️ Welcome"):
  569. st.info("Welcome to the secure Local Food AI environment.")
  570. if st.session_state["authenticated_user"]:
  571. st.success(f"Logged in as: {st.session_state['authenticated_user']}")
  572. if st.button("Logout"):
  573. st.session_state["logged_out"] = True
  574. st.session_state["authenticated_user"] = None
  575. cookie_manager.delete("auth_user")
  576. time.sleep(0.5)
  577. st.rerun()
  578. eav_data = get_eav_profile(st.session_state["authenticated_user"])
  579. uid = get_user_id(st.session_state["authenticated_user"])
  580. user_lim = get_user_limit(st.session_state["authenticated_user"])
  581. with st.expander("⚙️ Account Preferences"):
  582. opts = ["10", "20", "50", "100", "All"]
  583. idx = opts.index(user_lim) if user_lim in opts else 2
  584. new_lim = st.selectbox("Default Search Limit", opts, index=idx)
  585. if new_lim != user_lim:
  586. conn = get_db_connection('app_auth')
  587. with conn.cursor() as c:
  588. c.execute("UPDATE users SET search_limit = %s WHERE id = %s", (new_lim, uid))
  589. conn.commit()
  590. st.rerun()
  591. with st.expander("➕ Add Condition / Diet"):
  592. new_cat = st.selectbox("Category", ["Condition", "Illness", "Diet", "Dislike", "Allergy"])
  593. if new_cat == "Condition":
  594. new_val = st.selectbox("Value", ["Pregnant", "Breastfeeding", "Low Fat"])
  595. elif new_cat == "Illness":
  596. new_val = st.selectbox("Value", ["Diabetes", "Hypertension", "Kidney Disease", "Osteoporosis", "Scurvy", "Anemia"])
  597. elif new_cat == "Diet":
  598. new_val = st.selectbox("Value", ["Vegan", "Vegetarian", "Kosher", "Halal", "Christian", "Good Friday", "Ash Wednesday", "Keto", "Paleo"])
  599. else:
  600. new_val = st.text_input("Value (e.g. 'peanuts', 'broccoli')").strip()
  601. new_val_clean = new_val.lower()
  602. if st.button("Add to Profile") and new_val_clean and uid:
  603. conn = get_db_connection('app_auth')
  604. with conn.cursor() as c:
  605. 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.lower(), new_val_clean))
  606. conn.commit()
  607. st.rerun()
  608. if eav_data:
  609. st.markdown("#### Active Flags")
  610. for e in eav_data:
  611. col1, col2 = st.columns([4, 1])
  612. col1.info(f"**{e['name']}:** {e['value'].title()}")
  613. if col2.button("X", key=f"del_eav_{e['id']}"):
  614. conn = get_db_connection('app_auth')
  615. with conn.cursor() as c:
  616. c.execute("DELETE FROM user_health_profiles WHERE id = %s", (e['id'],))
  617. conn.commit()
  618. st.rerun()
  619. else:
  620. tab1, tab2, tab3 = st.tabs(["Login", "Register", "Reset"])
  621. with tab1:
  622. l_user = st.text_input("Username", key="l_user").strip()
  623. l_pass = st.text_input("Password", type="password", key="l_pass")
  624. if st.button("Login"):
  625. if verify_login(l_user, l_pass):
  626. notifier.send_alert(f"User Login Success: {l_user}")
  627. st.session_state["authenticated_user"] = l_user
  628. import datetime
  629. # Set cookie with 30 days expiration
  630. cookie_manager.set(
  631. "auth_user",
  632. l_user,
  633. expires_at=datetime.datetime.now() + datetime.timedelta(days=30)
  634. )
  635. time.sleep(0.2)
  636. st.rerun()
  637. else:
  638. notifier.send_alert(f"User Login Failed: {l_user}")
  639. st.error("Invalid login.")
  640. with tab2:
  641. r_user = st.text_input("Username", key="r_user")
  642. r_email = st.text_input("Email Address", key="r_email")
  643. r_pass = st.text_input("Password", type="password", key="r_pass")
  644. if st.button("Register"):
  645. if len(r_pass) < 6: st.error("Password too short.")
  646. elif register_user(r_user, r_pass, r_email): st.success("Registered safely!")
  647. else: st.error("Username exists.")
  648. with tab3:
  649. f_user = st.text_input("Username", key="f_user")
  650. f_email = st.text_input("Registered Email", key="f_email")
  651. if st.button("Send Reset Link"):
  652. status = reset_password(f_user, f_email)
  653. if status is True:
  654. st.success("Password reset emailed.")
  655. else:
  656. st.error(f"Failed: {status}")
  657. if not st.session_state["authenticated_user"]:
  658. st.title("🍔 Food AI Medical Explorer")
  659. st.info("Please login to interrogate the Clinical Data.")
  660. st.stop()
  661. st.title("🍔 Food AI Clinical Explorer")
  662. conn_reader = get_db_connection('app_reader')
  663. tab_chat, tab_explore, tab_plate, tab_planner = st.tabs(["💬 AI Chat", "🔬 Clinical Search", "🍽️ My Plate Builder", "🤖 AI Meal Planner"])
  664. import re
  665. with tab_chat:
  666. c1, c2 = st.columns([4, 1])
  667. c1.subheader("Chat with the Context")
  668. if c2.button("🧹 Clear Chat"):
  669. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  670. st.rerun()
  671. st.info("""
  672. ℹ️ **How to use this feature (Examples)**
  673. **Your active conditions (e.g. Pregnant, Diabetic) are automatically sent to the AI in the background. You do not need to type them out.**
  674. *Examples:*
  675. 1. "I am pregnant, diabetic, and have kidney problems. Can I eat sushi?"
  676. 2. "What is a safe snack to stabilize my blood sugar without hurting my kidneys?"
  677. 3. "Can I drink milk? I need calcium for the baby."
  678. 4. "Is it safe to eat a large steak for iron?"
  679. 5. "What foods are strictly forbidden for me?"
  680. """)
  681. if "messages" not in st.session_state:
  682. st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you analyze the food data today?"}]
  683. # Display chat history, filtering out TOOL_CALLS and XML tags
  684. for msg in st.session_state.messages:
  685. if msg["role"] == "tool": continue
  686. # Clean both old format [TOOL_CALLS] and new XML tags <sql_query>/<web_search>/<scratchpad>
  687. display_text = re.sub(r'\[TOOL_CALLS\]\s*\[.*?\]', '', msg["content"], flags=re.DOTALL)
  688. display_text = re.sub(r'<(sql_query|web_search|scratchpad)>.*?</\1>', '', display_text, flags=re.DOTALL).strip()
  689. if display_text:
  690. st.chat_message(msg["role"]).write(display_text)
  691. if prompt := st.chat_input("Ask a clinical question about your food..."):
  692. st.session_state.messages.append({"role": "user", "content": prompt})
  693. st.chat_message("user").write(prompt)
  694. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  695. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  696. sys_prompt = f"""You are a helpful, autonomous clinical dietitian and food analyst AI.
  697. You have access to a local MySQL database 'food_db' and a web search tool.
  698. You MUST determine dynamically when to query the database or search the web. Do not ask the user for permission.
  699. DATABASE SCHEMA (MySQL database 'food_db'):
  700. - Table 'products_core':
  701. * 'code' (VARCHAR(255) PRIMARY KEY): barcode/unique key
  702. * 'product_name' (LONGTEXT): name of product
  703. * 'generic_name' (LONGTEXT): description
  704. * 'brands' (LONGTEXT): brand
  705. * 'ingredients_text' (LONGTEXT): text ingredient list
  706. - Table 'products_macros':
  707. * 'code' (VARCHAR(255) PRIMARY KEY): joins products_core.code
  708. * 'energy-kcal_100g' (DOUBLE): calories per 100g
  709. * 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'fiber_100g', 'sodium_100g', 'salt_100g', 'cholesterol_100g' (all DOUBLE)
  710. - Table 'products_vitamins':
  711. * 'code' (VARCHAR(255) PRIMARY KEY): joins products_core.code
  712. * `vitamin-a_100g`, `vitamin-b1_100g`, `vitamin-b2_100g`, `vitamin-pp_100g`, `vitamin-b6_100g`, `vitamin-b9_100g`, `vitamin-b12_100g`, `vitamin-c_100g`, `vitamin-d_100g`, `vitamin-e_100g`, `vitamin-k_100g` (all DOUBLE)
  713. - Table 'products_minerals':
  714. * 'code' (VARCHAR(255) PRIMARY KEY): joins products_core.code
  715. * 'calcium_100g', 'iron_100g', 'magnesium_100g', 'potassium_100g', 'zinc_100g' (all DOUBLE)
  716. - Table 'products_allergens':
  717. * 'code' (VARCHAR(255) PRIMARY KEY): joins products_core.code
  718. * 'allergens' (LONGTEXT): comma-separated allergens list
  719. TOOL TAGS (Generate these inside your response when you need details):
  720. 1. To run a read-only SELECT query, write:
  721. <sql_query>SELECT ...</sql_query>
  722. Make sure to join tables on `code`. Optimize queries using the FULLTEXT indexes on `products_core` for maximum speed.
  723. Available FULLTEXT indexes:
  724. * `idx_prod_name` on `product_name`
  725. * `idx_ing_text` on `ingredients_text`
  726. * `idx_prod_ing` on `product_name, ingredients_text`
  727. Use MATCH() AGAINST('+word' IN BOOLEAN MODE) for searching. For example:
  728. <sql_query>SELECT c.product_name, m.proteins_100g FROM products_core c JOIN products_macros m ON c.code = m.code WHERE MATCH(c.product_name) AGAINST('+apple' IN BOOLEAN MODE) LIMIT 5;</sql_query>
  729. 2. To run a web search for information not in the database, write:
  730. <web_search>query</web_search>
  731. CRITICAL CLINICAL RULES:
  732. - When asked about any food's nutritional profile, you MUST provide its typical amino acid profile (such as tryptophan, lysine, methionine, threonine, valine, leucine, isoleucine, phenylalanine, histidine, etc.) by default. If the database does not contain exact values, calculate/estimate them based on the food's protein content using standard clinical formulas and your clinical knowledge.
  733. - Health profile constraints: {profile_text}. If the user's health profile indicates restrictions (e.g. low sodium for hypertension, low sugar for diabetes, low protein for kidney issues), analyze the foods they query against these criteria and issue a clear clinical warning (⚠️ or 💚).
  734. - Do not micromanage or guess if you can query the database. Query the database first for exact product data whenever possible!
  735. INSTRUCTIONS:
  736. - First think in a <scratchpad> about what tools (if any) you need.
  737. - If you need database or web data, output the tool tag. The system will run it and give you the result.
  738. - Once you have the context or if you don't need any tool, output your final answer directly. Keep your reasoning brief and focus on details.
  739. """
  740. # We start the loop for autonomous tool calling
  741. max_turns = 4
  742. current_turn = 0
  743. # Build messages list filtering system prompts
  744. temp_messages = [{"role": "system", "content": sys_prompt}]
  745. for m in st.session_state.messages:
  746. if m["role"] == "system": continue
  747. temp_messages.append({"role": m["role"], "content": m["content"]})
  748. try:
  749. full_assistant_reply = ""
  750. start_llm = time.time()
  751. with st.chat_message("assistant"):
  752. placeholder = st.empty()
  753. status_container = st.container()
  754. while current_turn < max_turns:
  755. current_turn += 1
  756. # Stream the response chunk by chunk
  757. response_stream = ollama.chat(model=get_active_model(), messages=temp_messages, stream=True)
  758. turn_reply = ""
  759. # Clean display text (strip scratchpad and tool tags while streaming)
  760. for chunk in response_stream:
  761. content = chunk['message']['content']
  762. turn_reply += content
  763. # Show stream output in real time
  764. display_text = re.sub(r'<(sql_query|web_search|scratchpad)>.*?</\1>', '', turn_reply, flags=re.DOTALL)
  765. display_text = re.sub(r'<(sql_query|web_search|scratchpad)>.*$', '', display_text, flags=re.DOTALL).strip()
  766. if display_text:
  767. placeholder.markdown(display_text)
  768. full_assistant_reply += "\n" + turn_reply
  769. # Check if there are tool tags in this turn's response
  770. sql_match = re.search(r'<sql_query>(.*?)</sql_query>', turn_reply, re.DOTALL)
  771. web_match = re.search(r'<web_search>(.*?)</web_search>', turn_reply, re.DOTALL)
  772. if sql_match:
  773. sql_query = sql_match.group(1).strip()
  774. with status_container.expander(f"🔍 Querying database: {sql_query}", expanded=True):
  775. db_results = query_sql_readonly(sql_query)
  776. st.write(db_results)
  777. # Add assistant output and tool result to context
  778. temp_messages.append({"role": "assistant", "content": turn_reply})
  779. temp_messages.append({"role": "user", "content": f"Database Results:\n{db_results}"})
  780. elif web_match:
  781. web_query = web_match.group(1).strip()
  782. with status_container.expander(f"🌐 Searching the web: {web_query}", expanded=True):
  783. web_results = query_web_searxng(web_query)
  784. st.write(web_results)
  785. temp_messages.append({"role": "assistant", "content": turn_reply})
  786. temp_messages.append({"role": "user", "content": f"Web Search Results:\n{web_results}"})
  787. else:
  788. # No tool calls generated. This is the final answer!
  789. break
  790. # Render the final cleaned reply to ensure it's displayed completely
  791. final_clean_reply = re.sub(r'<(sql_query|web_search|scratchpad)>.*?</\1>', '', turn_reply, flags=re.DOTALL).strip()
  792. placeholder.markdown(final_clean_reply)
  793. st.caption(f"⏱️ AI response generated in {time.time() - start_llm:.2f} seconds")
  794. st.session_state.messages.append({"role": "assistant", "content": turn_reply})
  795. except Exception as e:
  796. error_msg = str(e)
  797. if "not found" in error_msg.lower() or "404" in error_msg.lower():
  798. ai_reply = f"Hold on! Engine execution fault: {e}. Please check the LLM_MODEL variable in your .env file."
  799. else:
  800. ai_reply = f"Hold on! Engine execution fault: {e}"
  801. st.session_state.messages.append({"role": "assistant", "content": ai_reply})
  802. st.chat_message("assistant").write(ai_reply)
  803. def highlight_medical_warnings(row):
  804. try:
  805. val = str(row.get('Medical Warning', ''))
  806. if '⚠️' in val: return ['background-color: rgba(255, 0, 0, 0.4); color: white;'] * len(row)
  807. if '💚' in val: return ['background-color: rgba(0, 255, 0, 0.3); color: white;'] * len(row)
  808. except: pass
  809. return [''] * len(row)
  810. with tab_explore:
  811. st.subheader("Clinical Data Search")
  812. st.info("""
  813. ℹ️ **How to use this feature (Examples)**
  814. **Your active conditions are automatically flagged (⚠️ or 💚) in the search results.**
  815. *Example Searches:*
  816. 1. `Cereal` *(Checks for high sugar & hidden phosphorus)*
  817. 2. `Cheese` *(Checks for unpasteurized pregnancy risks & high sodium)*
  818. 3. `Fruit Juice` *(Checks for high sugar spikes)*
  819. 4. `Deli Meat` *(Checks for Listeria risk & extreme sodium)*
  820. 5. `White Rice` *(Safe for kidneys but flags high glycemic index)*
  821. """)
  822. with st.form("explore_search_form"):
  823. sq = st.text_input("Search Product Name or Ingredient")
  824. cols = st.columns(5)
  825. min_pro = cols[0].number_input("Min Protein (g)", 0, 1000, 0)
  826. min_fat = cols[1].number_input("Min Fat (g)", 0, 1000, 0)
  827. min_carb = cols[2].number_input("Min Carbs (g)", 0, 1000, 0)
  828. max_sug = cols[3].number_input("Max Sugar (g)", 0, 1000, 1000)
  829. # Load dynamically fetched limit to prevent Pandas Styler crash
  830. pd.set_option("styler.render.max_elements", 5000000)
  831. opts = [10, 50, 100, 500, 1000]
  832. user_lim_str = get_user_limit(st.session_state["authenticated_user"])
  833. user_lim_val = 1000 if user_lim_str == "All" else int(user_lim_str)
  834. if user_lim_val not in opts: user_lim_val = 50
  835. idx = opts.index(user_lim_val)
  836. limit_rc = cols[4].selectbox("Limit Results", opts, index=idx)
  837. submit_search = st.form_submit_button("Search Database")
  838. if submit_search:
  839. st.session_state["trigger_search"] = True
  840. if st.session_state.get("trigger_search", False) and sq and conn_reader:
  841. notifier.send_alert(f"Medical DB Search Executed: {sq}")
  842. with st.spinner("Processing massive clinical query..."):
  843. try:
  844. with conn_reader.cursor() as cursor:
  845. l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
  846. query = f"""
  847. SELECT c.code, c.product_name, c.generic_name, c.brands, c.ingredients_text,
  848. c.url, c.image_url, c.image_small_url, c.image_ingredients_url,
  849. c.image_ingredients_small_url, c.image_nutrition_url, c.image_nutrition_small_url,
  850. a.allergens,
  851. 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,
  852. 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`,
  853. min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
  854. FROM (
  855. SELECT code, product_name, generic_name, brands, ingredients_text,
  856. url, image_url, image_small_url, image_ingredients_url,
  857. image_ingredients_small_url, image_nutrition_url, image_nutrition_small_url
  858. FROM food_db.products_core
  859. WHERE (MATCH(product_name, ingredients_text) AGAINST(%s IN BOOLEAN MODE) OR product_name LIKE %s)
  860. AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
  861. ORDER BY MATCH(product_name) AGAINST(%s IN BOOLEAN MODE) DESC, MATCH(ingredients_text) AGAINST(%s IN BOOLEAN MODE) DESC
  862. {l_str}
  863. ) c
  864. LEFT JOIN food_db.products_allergens a ON c.code = a.code
  865. LEFT JOIN food_db.products_macros m ON c.code = m.code
  866. LEFT JOIN food_db.products_vitamins v ON c.code = v.code
  867. LEFT JOIN food_db.products_minerals min ON c.code = min.code
  868. WHERE (m.proteins_100g >= %s OR m.proteins_100g IS NULL)
  869. AND (m.fat_100g >= %s OR m.fat_100g IS NULL)
  870. AND (m.carbohydrates_100g >= %s OR m.carbohydrates_100g IS NULL)
  871. AND (m.sugars_100g <= %s OR m.sugars_100g IS NULL)
  872. """
  873. sq_bool = " ".join([f"+{w}" for w in sq.split()])
  874. sq_like = f"%{sq}%"
  875. start_time = time.time()
  876. cursor.execute(query, (sq_bool, sq_like, sq_bool, sq_bool, min_pro, min_fat, min_carb, max_sug))
  877. results = cursor.fetchall()
  878. elapsed = time.time() - start_time
  879. st.caption(f"⏱️ Execution Trace: Module=MySQL | Time={elapsed:.3f} seconds")
  880. if results:
  881. # Fetch EAV Medical Profile
  882. eav_profile = get_eav_profile(st.session_state["authenticated_user"])
  883. df = pd.DataFrame(results)
  884. df.replace(r'^\s*$', None, regex=True, inplace=True)
  885. for col in df.columns:
  886. if col.endswith('_100g'):
  887. df[col] = pd.to_numeric(df[col], errors='coerce')
  888. st.markdown("### 🛠️ Dynamic Column Display")
  889. default_columns = [
  890. 'code', 'product_name', 'generic_name', 'brands', 'image_small_url', 'allergens', 'ingredients_text',
  891. 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'sodium_100g', 'energy-kcal_100g',
  892. 'vitamin-c_100g', 'iron_100g', 'calcium_100g'
  893. ]
  894. all_fetched_cols = list(df.columns)
  895. valid_defaults = [c for c in default_columns if c in all_fetched_cols]
  896. if "selected_columns" not in st.session_state or st.button("Reset Default Columns"):
  897. st.session_state["selected_columns"] = valid_defaults
  898. st.rerun()
  899. chosen_cols = st.multiselect("Customize Dataset View", all_fetched_cols, default=st.session_state["selected_columns"], key="multi_cols")
  900. st.session_state["selected_columns"] = chosen_cols
  901. # Filter dataframe gracefully, but we retain a copy for background analytics
  902. df_display = df[chosen_cols].copy()
  903. warnings_col = []
  904. for idx, row in df.iterrows():
  905. warns = []
  906. ing_text = str(row['ingredients_text']).lower()
  907. all_text = str(row['allergens']).lower()
  908. for param in eav_profile:
  909. cat = param['name'].lower()
  910. val = param['value']
  911. # Disease Analytics
  912. if cat == 'illness':
  913. if val == 'diabetes' and pd.notnull(row.get('sugars_100g')) and float(row['sugars_100g']) > 10.0:
  914. warns.append("⚠️ High Sugar (Diabetes)")
  915. if (val == 'hypertension' or val == 'high bp') and pd.notnull(row.get('sodium_100g')) and float(row['sodium_100g']) > 1.5:
  916. warns.append("⚠️ High Salt (Hypertension)")
  917. if val == 'scurvy' and pd.notnull(row.get('vitamin-c_100g')) and float(row['vitamin-c_100g']) > 0.005:
  918. warns.append("💚 High Vitamin C (Scurvy Recommended)")
  919. if val == 'anemia' and pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  920. warns.append("💚 High Iron (Anemia Recommended)")
  921. # Condition Analytics
  922. if cat == 'condition':
  923. if val == 'pregnant':
  924. if ('cru' in ing_text or 'raw' in ing_text or 'viande crue' in ing_text):
  925. warns.append("⚠️ Raw Foods (Pregnancy Toxoplasmosis)")
  926. if pd.notnull(row.get('iron_100g')) and float(row['iron_100g']) > 0.002:
  927. warns.append("💚 Med-High Iron (Pregnancy Health)")
  928. if val == 'low fat' and pd.notnull(row.get('fat_100g')) and float(row['fat_100g']) > 20.0:
  929. warns.append("⚠️ High Fat")
  930. if val == 'osteoporosis' and pd.notnull(row.get('calcium_100g')) and float(row['calcium_100g']) > 0.1:
  931. warns.append("💚 High Calcium (Bone Health)")
  932. if eav_data:
  933. ing_text = str(row.get('ingredients_text', '')).lower()
  934. all_text = str(row.get('allergens', '')).lower()
  935. product_name_text = str(row.get('product_name', '')).lower()
  936. for e in eav_data:
  937. cat = str(e['name']).lower()
  938. val = str(e['value']).lower()
  939. # Clinical Trace Checks...
  940. if cat == 'condition' and (val == 'pregnant' or val == 'pregnancy' or val == 'breastfeeding'):
  941. # Forbidden / High Risk (Toxoplasmosis & Listeria)
  942. if any(x in ing_text or x in product_name_text for x in ['cru', 'raw', 'viande crue', 'sushi', 'sashimi', 'poisson cru']):
  943. warns.append("⚠️ Forbidden: Raw Meat/Fish (Toxoplasmosis/Parasite Risk)")
  944. if any(x in ing_text or x in product_name_text for x in ['lait cru', 'unpasteurized', 'non pasteurisé']):
  945. warns.append("⚠️ Forbidden: Unpasteurized Dairy (Listeria Risk)")
  946. if any(x in ing_text or x in product_name_text for x in ['alcool', 'wine', 'alcohol', 'beer']):
  947. warns.append("⚠️ Forbidden: Contains Alcohol")
  948. # Recommended (Iron & Calcium)
  949. if float(row.get('iron_100g', 0) or 0) > 0.003:
  950. warns.append("💚 Recommended: High Iron (Pregnancy Health)")
  951. if float(row.get('calcium_100g', 0) or 0) > 0.120:
  952. warns.append("💚 Recommended: High Calcium (Bone Health / Breastfeeding)")
  953. if cat == 'illness' and val == 'osteoporosis':
  954. if float(row.get('calcium_100g', 0) or 0) < 0.120:
  955. warns.append("⚠️ Low Calcium (Osteoporosis Risk)")
  956. else:
  957. warns.append("💚 Recommended (High Calcium)")
  958. if cat == 'illness' and val == 'scurvy':
  959. if float(row.get('vitamin-c_100g', 0) or 0) < 0.010:
  960. warns.append("⚠️ Low Vitamin C (Scurvy Risk)")
  961. else:
  962. warns.append("💚 Recommended (High Vitamin C)")
  963. if cat == 'diet' and val in ['vegan', 'vegetarian']:
  964. if any(x in ing_text for x in ['meat', 'beef', 'chicken', 'fish', 'gelatin', 'whey', 'pork', 'porc', 'poulet']):
  965. warns.append("⚠️ Contains Animal Products")
  966. if cat == 'diet' and val == 'halal':
  967. if any(x in ing_text for x in ['pork', 'pig', 'porc', 'wine', 'alcohol', 'beer', 'vin']):
  968. warns.append("⚠️ Probable Haram Ingredients (e.g. Pork/Wine)")
  969. if cat in ['dislike', 'allergy']:
  970. if val in ing_text or val in all_text or val in product_name_text:
  971. warns.append(f"⚠️ Contains: {val.upper()}")
  972. warnings_col.append(" | ".join(list(set(warns))) if warns else "✅ Safe for Profile")
  973. df_display.insert(0, 'Medical Warning', warnings_col)
  974. # Clean image URLs in df_display before displaying
  975. for col in df_display.columns:
  976. if 'image' in col.lower():
  977. df_display[col] = df_display[col].apply(lambda x: x if is_valid_image_url(x) else "")
  978. df_display.replace(to_replace=[None, 'None', 'nan', 'NaN'], value='\u00a0', inplace=True)
  979. # Only fillna with empty string on object columns to avoid Arrow float64 conversion errors
  980. for col in df_display.columns:
  981. if df_display[col].dtype == 'object':
  982. df_display[col] = df_display[col].fillna("")
  983. df_display.index = range(1, len(df_display) + 1)
  984. styled_df = df_display.style.apply(highlight_medical_warnings, axis=1)
  985. col_configs = {}
  986. for col in df_display.columns:
  987. if 'image' in col.lower():
  988. col_configs[col] = st.column_config.ImageColumn(col.replace('_', ' ').title())
  989. st.success(f"Analysed {len(results)} records utilizing dynamic Partitions!")
  990. st.dataframe(styled_df, column_config=col_configs, use_container_width=True, hide_index=True)
  991. if st.button("🤖 Ask AI to Evaluate This Table"):
  992. with st.spinner("AI is dynamically evaluating these records against your profile..."):
  993. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  994. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  995. start_eval = time.time()
  996. minimal_records = df_display[['product_name', 'Medical Warning']].head(10).to_dict('records')
  997. eval_prompt = f"The user has this profile: {profile_text}. Evaluate these top foods and state which are highly recommended or strictly forbidden: {minimal_records}. Be extremely precise regarding carbohydrate content and do not hallucinate any values. Provide a direct, readable clinical summary. Do not output raw JSON."
  998. try:
  999. response = ollama.chat(model=get_active_model(), messages=[{'role': 'user', 'content': eval_prompt}], stream=True)
  1000. st.write_stream(chunk['message']['content'] for chunk in response)
  1001. elapsed_eval = time.time() - start_eval
  1002. st.caption(f"⏱️ Execution Trace: Module=Ollama | Time={elapsed_eval:.2f} seconds")
  1003. except Exception as e:
  1004. error_msg = str(e).lower()
  1005. if "404" in error_msg or "not found" in error_msg:
  1006. st.warning("⚠️ The AI engine is currently downloading its core models in the background. Please wait a minute and try again!")
  1007. else:
  1008. st.error(f"AI Evaluation Failed: {e}")
  1009. else:
  1010. st.warning("No products found matching those strict terms.")
  1011. except Exception as e: st.error(f"SQL/Pandas Error: {e}")
  1012. with tab_plate:
  1013. st.subheader("🍽️ My Plate Builder")
  1014. st.info("""
  1015. ℹ️ **How to use this feature (Examples & Logic)**
  1016. **Plate Builder Logic:**
  1017. 1. Create a New Plate.
  1018. 2. Search for exact food words (e.g. 'chicken', 'egg').
  1019. 3. Add the food with a specific portion (e.g. '150g').
  1020. 4. The system calculates the combined macros.
  1021. 5. Use the 🗑️ buttons to delete incorrect items or entire plates.
  1022. *Example Plates:*
  1023. 1. `add White Rice use 150g then add Chicken Breast use 50g add Green Beans use 100g`
  1024. 2. `add Potatoes use 200g then add Tomatoes use 100g add Beef use 100g`
  1025. 3. `add Spinach Salad use 100g then add Feta Cheese use 50g`
  1026. 4. `add Lentils use 200g then add Quinoa use 100g`
  1027. 5. `add Apple use 100g then add Almonds use 30g`
  1028. """)
  1029. uid = get_user_id(st.session_state["authenticated_user"])
  1030. conn = get_db_connection('app_auth')
  1031. if conn and uid:
  1032. with conn.cursor() as cursor:
  1033. cursor.execute("SELECT id, plate_name FROM plates WHERE user_id = %s", (uid,))
  1034. plates = cursor.fetchall()
  1035. st.markdown("#### ➕ Create a New Plate")
  1036. col_p1, col_p2 = st.columns([3, 1])
  1037. new_plate_name = col_p1.text_input("Plate Name (e.g., 'Spaghetti Bolognese')", key="new_plate")
  1038. if col_p2.button("Create Plate", use_container_width=True) and new_plate_name:
  1039. cursor.execute("INSERT INTO plates (user_id, plate_name) VALUES (%s, %s)", (uid, new_plate_name))
  1040. conn.commit()
  1041. st.session_state["active_plate"] = new_plate_name
  1042. st.rerun()
  1043. st.markdown("---")
  1044. if plates:
  1045. colA, colB = st.columns([4, 1])
  1046. plate_names = [p['plate_name'] for p in plates]
  1047. 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
  1048. selected_plate = colA.selectbox("Select Active Plate to Edit Ingredients", plate_names, index=default_idx)
  1049. st.session_state["active_plate"] = selected_plate
  1050. active_p_id = next(p['id'] for p in plates if p['plate_name'] == selected_plate)
  1051. if colB.button("🗑️ Delete Plate"):
  1052. cursor.execute("DELETE FROM plates WHERE id = %s", (active_p_id,))
  1053. conn.commit()
  1054. if "active_plate" in st.session_state: del st.session_state["active_plate"]
  1055. st.rerun()
  1056. cursor.execute("""
  1057. SELECT i.id, i.product_code, MAX(i.quantity_grams) as quantity_grams,
  1058. MAX(p.product_name) as product_name, MAX(p.ingredients_text) as ingredients_text,
  1059. MAX(m.proteins_100g) as proteins_100g, MAX(m.fat_100g) as fat_100g, MAX(m.carbohydrates_100g) as carbohydrates_100g,
  1060. MAX(m.sodium_100g) as sodium_100g, MAX(m.sugars_100g) as sugars_100g, MAX(m.fiber_100g) as fiber_100g,
  1061. MAX(v.`vitamin-a_100g`) as vitamin_a_100g, MAX(v.`vitamin-b1_100g`) as vitamin_b1_100g,
  1062. MAX(v.`vitamin-b2_100g`) as vitamin_b2_100g, MAX(v.`vitamin-pp_100g`) as vitamin_pp_100g,
  1063. MAX(v.`vitamin-b6_100g`) as vitamin_b6_100g, MAX(v.`vitamin-b9_100g`) as vitamin_b9_100g,
  1064. MAX(v.`vitamin-b12_100g`) as vitamin_b12_100g, MAX(v.`vitamin-c_100g`) as vitamin_c_100g,
  1065. MAX(v.`vitamin-d_100g`) as vitamin_d_100g, MAX(v.`vitamin-e_100g`) as vitamin_e_100g,
  1066. MAX(v.`vitamin-k_100g`) as vitamin_k_100g,
  1067. MAX(min.calcium_100g) as calcium_100g, MAX(min.iron_100g) as iron_100g,
  1068. MAX(min.magnesium_100g) as magnesium_100g, MAX(min.potassium_100g) as potassium_100g,
  1069. MAX(min.zinc_100g) as zinc_100g,
  1070. GROUP_CONCAT(DISTINCT a.allergens SEPARATOR ', ') as allergens
  1071. FROM plate_items i
  1072. LEFT JOIN products_core p ON i.product_code = p.code
  1073. LEFT JOIN products_macros m ON i.product_code = m.code
  1074. LEFT JOIN products_vitamins v ON i.product_code = v.code
  1075. LEFT JOIN products_minerals min ON i.product_code = min.code
  1076. LEFT JOIN products_allergens a ON i.product_code = a.code
  1077. WHERE i.plate_id = %s
  1078. GROUP BY i.id, i.product_code
  1079. """, (active_p_id,))
  1080. items = cursor.fetchall()
  1081. if items:
  1082. for i in items:
  1083. c1, c2 = st.columns([5, 1])
  1084. pro = float(i['proteins_100g'] or 0) * (float(i['quantity_grams'])/100.0)
  1085. fat = float(i['fat_100g'] or 0) * (float(i['quantity_grams'])/100.0)
  1086. carb = float(i['carbohydrates_100g'] or 0) * (float(i['quantity_grams'])/100.0)
  1087. c1.markdown(f"<li><b>{i['quantity_grams']}g</b> of {i['product_name']} (Pro: {pro:.2f}g | Fat: {fat:.2f}g | Carb: {carb:.2f}g)</li>", unsafe_allow_html=True)
  1088. if c2.button("🗑️", key=f"del_item_{i['id']}"):
  1089. cursor.execute("DELETE FROM plate_items WHERE id = %s", (i['id'],))
  1090. conn.commit()
  1091. st.rerun()
  1092. totals = {
  1093. "Total Protein (g)": sum((float(i['proteins_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1094. "Total Fat (g)": sum((float(i['fat_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1095. "Total Carbs (g)": sum((float(i['carbohydrates_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1096. "Sodium (g)": sum((float(i['sodium_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1097. "Sugars (g)": sum((float(i['sugars_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1098. "Fiber (g)": sum((float(i['fiber_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1099. "Vitamin A (g)": sum((float(i['vitamin_a_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1100. "Vitamin B1 (g)": sum((float(i['vitamin_b1_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1101. "Vitamin B2 (g)": sum((float(i['vitamin_b2_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1102. "Vitamin B3/PP (g)": sum((float(i['vitamin_pp_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1103. "Vitamin B6 (g)": sum((float(i['vitamin_b6_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1104. "Vitamin B9 (g)": sum((float(i['vitamin_b9_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1105. "Vitamin B12 (g)": sum((float(i['vitamin_b12_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1106. "Vitamin C (g)": sum((float(i['vitamin_c_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1107. "Vitamin D (g)": sum((float(i['vitamin_d_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1108. "Vitamin E (g)": sum((float(i['vitamin_e_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1109. "Vitamin K (g)": sum((float(i['vitamin_k_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1110. "Calcium (g)": sum((float(i['calcium_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1111. "Iron (g)": sum((float(i['iron_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1112. "Magnesium (g)": sum((float(i['magnesium_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1113. "Potassium (g)": sum((float(i['potassium_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1114. "Zinc (g)": sum((float(i['zinc_100g'] or 0) * (float(i['quantity_grams'])/100.0)) for i in items),
  1115. }
  1116. st.markdown("---")
  1117. st.markdown("### Plate Totals")
  1118. metrics = list(totals.items())
  1119. for idx in range(0, len(metrics), 3):
  1120. cols = st.columns(3)
  1121. for j in range(3):
  1122. if idx + j < len(metrics):
  1123. name, val = metrics[idx + j]
  1124. cols[j].metric(name, f"{val:.5f}" if val < 0.1 else f"{val:.2f}")
  1125. aliments_list = []
  1126. for i in items:
  1127. prod_name = str(i.get('product_name') or '').strip()
  1128. if prod_name and prod_name.lower() not in ['none', '']:
  1129. aliments_list.append(prod_name)
  1130. ing_text = str(i.get('ingredients_text') or '').strip()
  1131. if ing_text:
  1132. import re
  1133. parts = re.split(r'[,;()\[\]\n\r]', ing_text)
  1134. for p in parts:
  1135. p_clean = re.sub(r'[*_\d%]+', '', p).strip()
  1136. if len(p_clean) > 2 and p_clean.lower() not in ['ingredients', 'and', 'contains', 'may contain', 'natural', 'artificial', 'flavors', 'flavor', 'preservative', 'color', 'colors']:
  1137. aliments_list.append(p_clean)
  1138. seen = set()
  1139. unique_aliments = []
  1140. for a in aliments_list:
  1141. a_low = a.lower()
  1142. if a_low not in seen:
  1143. seen.add(a_low)
  1144. unique_aliments.append(a)
  1145. table_data = query_plate_allergens(unique_aliments)
  1146. st.markdown("---")
  1147. if table_data:
  1148. st.warning("⚠️ **Plate Allergens Detected:**")
  1149. import pandas as pd
  1150. st.table(pd.DataFrame(table_data))
  1151. else:
  1152. st.success("✅ **No Allergens Detected**")
  1153. st.markdown("---")
  1154. st.markdown("#### ➕ Add Food to Plate")
  1155. with st.form("plate_add_form"):
  1156. add_search = st.text_input("Search Exact Product Name (e.g. 'chicken', 'egg')")
  1157. col_scope, col_comp = st.columns(2)
  1158. search_scope = col_scope.radio("Search Scope", ["Auto (Cascaded)", "Product Name Only", "Both (Product & Ingredients)", "Ingredients Only"], horizontal=True)
  1159. comp_reqs = col_comp.multiselect("Require Nutrients (Sorts by highest)", ["Iron", "Vitamin C", "Calcium", "Proteins", "Fiber"])
  1160. raw_ingredient_filter = col_scope.radio("Raw Ingredient Only?", ["No", "Yes"], horizontal=True)
  1161. submit_add_search = st.form_submit_button("Search Food")
  1162. if add_search and submit_add_search:
  1163. bool_search = " ".join([f"+{w}" for w in add_search.split()])
  1164. start_time = time.time()
  1165. def execute_search(match_col_override=None):
  1166. m_col = "product_name"
  1167. if match_col_override: m_col = match_col_override
  1168. elif "Both" in search_scope: m_col = "product_name, ingredients_text"
  1169. elif "Ingredients" in search_scope: m_col = "ingredients_text"
  1170. join_min = "LEFT JOIN food_db.products_minerals min ON c.code = min.code" if any(n in comp_reqs for n in ["Iron", "Calcium"]) else ""
  1171. join_vit = "LEFT JOIN food_db.products_vitamins v ON c.code = v.code" if "Vitamin C" in comp_reqs else ""
  1172. r_clauses, o_clauses = [], []
  1173. if "Iron" in comp_reqs: r_clauses.append("min.iron_100g > 0"); o_clauses.append("min.iron_100g DESC")
  1174. if "Vitamin C" in comp_reqs: r_clauses.append("v.`vitamin-c_100g` > 0"); o_clauses.append("v.`vitamin-c_100g` DESC")
  1175. if "Calcium" in comp_reqs: r_clauses.append("min.calcium_100g > 0"); o_clauses.append("min.calcium_100g DESC")
  1176. if "Proteins" in comp_reqs: r_clauses.append("m.proteins_100g > 0"); o_clauses.append("m.proteins_100g DESC")
  1177. if "Fiber" in comp_reqs: r_clauses.append("m.fiber_100g > 0"); o_clauses.append("m.fiber_100g DESC")
  1178. wh_comp = " AND " + " AND ".join(r_clauses) if r_clauses else ""
  1179. order_by = "ORDER BY " + ", ".join(o_clauses) if o_clauses else ""
  1180. raw_clause = ""
  1181. # Note: raw ingredient filter is bypassed since image columns are omitted on the server schema
  1182. sql = f"""
  1183. SELECT c.code, c.product_name, c.image_small_url, c.image_ingredients_small_url, c.image_nutrition_small_url
  1184. FROM (
  1185. SELECT code, product_name, image_small_url, image_ingredients_small_url, image_nutrition_small_url
  1186. FROM food_db.products_core
  1187. WHERE MATCH({m_col}) AGAINST(%s IN BOOLEAN MODE)
  1188. AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
  1189. {raw_clause}
  1190. ORDER BY LENGTH(product_name) ASC
  1191. ) c
  1192. JOIN food_db.products_macros m ON c.code = m.code
  1193. {join_min}
  1194. {join_vit}
  1195. WHERE m.proteins_100g IS NOT NULL AND m.fat_100g IS NOT NULL AND m.carbohydrates_100g IS NOT NULL
  1196. {wh_comp}
  1197. {order_by}
  1198. """
  1199. cursor.execute(sql, (bool_search,))
  1200. return cursor.fetchall()
  1201. search_res = execute_search()
  1202. if not search_res and search_scope == "Auto (Cascaded)":
  1203. st.warning("No product found in names, so I am looking into the ingredients...")
  1204. search_res = execute_search("ingredients_text")
  1205. elapsed = time.time() - start_time
  1206. st.caption(f"⏱️ Execution Trace: Module=MySQL | Time={elapsed:.3f} seconds")
  1207. st.session_state['plate_search_res'] = search_res
  1208. if st.session_state.get('plate_search_res'):
  1209. search_res = st.session_state['plate_search_res']
  1210. # Select Product Table Gallery
  1211. st.markdown("##### 🔍 Found Products Preview")
  1212. df_rows = []
  1213. for r in search_res:
  1214. df_rows.append({
  1215. "Code": r['code'],
  1216. "Product Name": r['product_name'],
  1217. "Image": r.get('image_small_url') if is_valid_image_url(r.get('image_small_url')) else "",
  1218. "Ingredients Image": r.get('image_ingredients_small_url') if is_valid_image_url(r.get('image_ingredients_small_url')) else "",
  1219. "Nutrition Image": r.get('image_nutrition_small_url') if is_valid_image_url(r.get('image_nutrition_small_url')) else "",
  1220. })
  1221. gallery_df = pd.DataFrame(df_rows)
  1222. gallery_df.replace(to_replace=[None, 'None', 'nan', 'NaN'], value='\u00a0', inplace=True)
  1223. gallery_df.index = range(1, len(gallery_df) + 1)
  1224. event = st.dataframe(
  1225. gallery_df,
  1226. column_config={
  1227. "Image": st.column_config.ImageColumn("Image"),
  1228. "Ingredients Image": st.column_config.ImageColumn("Ingredients"),
  1229. "Nutrition Image": st.column_config.ImageColumn("Nutrition"),
  1230. },
  1231. use_container_width=True,
  1232. on_select="rerun",
  1233. selection_mode="single-row",
  1234. key="product_preview_table"
  1235. )
  1236. selected_row_idx = None
  1237. if event and hasattr(event, "selection"):
  1238. sel = event.selection
  1239. if isinstance(sel, dict) and sel.get("rows"):
  1240. selected_row_idx = sel["rows"][0]
  1241. elif hasattr(sel, "get") and sel.get("rows"):
  1242. selected_row_idx = sel.get("rows")[0]
  1243. options = {f"{r['product_name']} ({r['code']})": r for r in search_res}
  1244. options_list = list(options.keys())
  1245. default_idx = 0
  1246. if selected_row_idx is not None and selected_row_idx < len(options_list):
  1247. default_idx = selected_row_idx
  1248. selected_str = st.selectbox("Select Product", options_list, index=default_idx)
  1249. selected_product = options[selected_str]
  1250. add_amount_str = st.text_input("Portion Quantity (e.g., '100g', '2 tbsp', '1.5 cups', '1 pinch')", value="100g")
  1251. if st.button("Add Item to Plate"):
  1252. start_add = time.time()
  1253. # Use UnitConverter to parse
  1254. grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
  1255. if grams is not None:
  1256. cursor.execute("INSERT INTO plate_items (plate_id, product_code, quantity_grams) VALUES (%s, %s, %s)",
  1257. (active_p_id, selected_product['code'], grams))
  1258. conn.commit()
  1259. st.success(f"Added {grams}g of {selected_product['product_name']}!")
  1260. elapsed_add = time.time() - start_add
  1261. st.caption(f"⏱️ Execution Trace: Module=UnitConverter, MySQL | Time={elapsed_add:.3f} seconds")
  1262. st.session_state.pop('plate_search_res', None)
  1263. st.rerun()
  1264. else:
  1265. st.error("Could not parse unit. Please use format like '100g' or '1 cup'.")
  1266. elif add_search and submit_add_search:
  1267. st.warning("No products found.")
  1268. with tab_planner:
  1269. st.subheader("🤖 AI Meal Planner")
  1270. st.info("""
  1271. ℹ️ **How to use this feature (Examples)**
  1272. **Your active conditions are automatically applied to the generated menu.**
  1273. *Example Prompts:*
  1274. 1. "Generate a full day meal plan for me. I am pregnant, diabetic, and have kidney disease."
  1275. 2. "Plan a pregnancy-safe dinner that won't spike my blood sugar."
  1276. 3. "I need a high-iron lunch that is safe for my kidneys."
  1277. 4. "Plan a breakfast without dairy that is kidney-friendly."
  1278. 5. "Give me a 3-day meal prep plan ensuring no raw fish, controlled protein portions, and steady complex carbs."
  1279. """)
  1280. p_col1, p_col2, p_col3 = st.columns(3)
  1281. target_cal = p_col1.number_input("Target Daily Calories (kcal)", 1000, 5000, 2000, 50)
  1282. diet_pref = p_col2.selectbox("Dietary Preference", ["Omnivore", "Vegetarian", "Vegan", "Keto", "Paleo"])
  1283. meal_count = p_col3.slider("Number of Meals", 1, 6, 3)
  1284. extra_notes = st.text_input("Any additional allergies or goals?")
  1285. if st.button("Generate Professional Menu"):
  1286. st.session_state.pop("generated_meal_plan", None)
  1287. st.session_state.pop("meal_plan_pdf_data", None)
  1288. st.session_state.pop("meal_plan_elapsed", None)
  1289. with st.spinner("Executing Lightning-Fast Context RAG..."):
  1290. user_eav = get_eav_profile(st.session_state["authenticated_user"])
  1291. profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
  1292. # Pre-fetch database context directly without using AI tools!
  1293. # Enforce the strict clinical constraints directly via SQL
  1294. db_context = search_nutrition_db(diet_pref, user_eav)
  1295. meal_names = ["Breakfast", "Lunch", "Dinner", "Morning Snack", "Afternoon Snack", "Evening Snack"]
  1296. selected_meals = ", ".join(meal_names[:int(meal_count)])
  1297. sys_prompt = f"""You are a professional clinical Dietitian planner. Target: {target_cal}kcal.
  1298. You MUST generate EXACTLY {meal_count} meals and NO MORE. Failure to respect the meal count is a critical clinical error.
  1299. The allowed meal(s) are strictly: {selected_meals}.
  1300. Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
  1301. Health profile: {profile_text}.
  1302. - Under no circumstances should you hallucinate any nutritional values. No hallucinations.
  1303. - Base all calculations and values strictly on the database context provided: {db_context}.
  1304. COGNITIVE SCRATCHPAD INSTRUCTIONS:
  1305. - You MUST perform all your intermediate thinking, unit conversions (e.g. converting cups, tablespoons, or ounces to exact metric grams based on food density), and calorie/protein mathematical additions inside a `<scratchpad>` tag.
  1306. - Format:
  1307. <scratchpad>
  1308. Calculations:
  1309. - 1.5 cups of Cheese = X grams (density Y). Calories = A, Protein = B, Carbs = C, Fat = D.
  1310. - 2 tbsp of Peanut Butter = Z grams (density C). Calories = D, Protein = E, Carbs = F, Fat = G.
  1311. - Summation: Total Calories = A + D = Z kcal (vs target {target_cal}kcal). Total Protein = B + E = Fg.
  1312. - </scratchpad>
  1313. | Meal Time | Exact Food | Portion Size | Calories | Protein | Carbs | Fat |
  1314. | --- | --- | --- | --- | --- | --- | --- |
  1315. ...
  1316. | Global Total | All Meals | | Total Calories | Total Protein | Total Carbs | Total Fat |
  1317. CRITICAL FORMATTING INSTRUCTIONS:
  1318. - After the </scratchpad> closing tag, you MUST strictly output the menu formatted as a Markdown Table.
  1319. - The table MUST contain exactly 7 columns separated by pipes (|): | Meal Time | Exact Food | Portion Size | Calories | Protein | Carbs | Fat |
  1320. - The Portion Size MUST be reported in exactly metric grams (e.g. 200g) and NEVER in cups or oz.
  1321. - The items in the table MUST be selected strictly from: {db_context}
  1322. - Do NOT output JSON. Do NOT use tool calls. Skip pleasantries.
  1323. """
  1324. st.info("🧠 AI is analyzing nutritional synergies and generating your plan...")
  1325. # Stream the response instantly!
  1326. try:
  1327. start_llm = time.time()
  1328. placeholder = st.empty()
  1329. response = ollama.chat(model=get_active_model(), messages=[
  1330. {'role': 'system', 'content': sys_prompt},
  1331. {'role': 'user', 'content': 'Generate my meal plan as a markdown table.'}
  1332. ], stream=True)
  1333. raw_chunks = []
  1334. clean_stream = filter_scratchpad_stream(response, raw_chunks)
  1335. ai_reply = placeholder.write_stream(clean_stream)
  1336. raw_reply = "".join(raw_chunks)
  1337. # Strip scratchpad and calculate totals
  1338. clean_reply = strip_scratchpad(raw_reply)
  1339. def add_total_row_to_markdown_table(text):
  1340. import re
  1341. lines = text.split('\n')
  1342. table_start = -1
  1343. table_end = -1
  1344. for i, line in enumerate(lines):
  1345. line_s = line.strip()
  1346. if line_s.startswith('|') and line_s.endswith('|'):
  1347. if 'Meal Time' in line or 'Exact Food' in line or 'Portion' in line:
  1348. if table_start == -1:
  1349. table_start = i
  1350. if table_start != -1:
  1351. table_end = i
  1352. if table_start == -1 or table_end == -1:
  1353. return text
  1354. total_cal, total_pro, total_carb, total_fat = 0.0, 0.0, 0.0, 0.0
  1355. for idx in range(table_start + 2, table_end + 1):
  1356. line = lines[idx].strip()
  1357. if not (line.startswith('|') and line.endswith('|')):
  1358. continue
  1359. cols = [c.strip() for c in line.strip('|').split('|')]
  1360. if len(cols) < 7:
  1361. continue
  1362. if any(w in cols[0].lower() or w in cols[1].lower() for w in ['total', 'summation', 'global']):
  1363. continue
  1364. def clean_num(val):
  1365. nums = re.findall(r'([0-9]+(?:\.[0-9]+)?)', val)
  1366. return float(nums[-1]) if nums else 0.0
  1367. total_cal += clean_num(cols[3])
  1368. total_pro += clean_num(cols[4])
  1369. total_carb += clean_num(cols[5])
  1370. total_fat += clean_num(cols[6])
  1371. total_row = f"| Total Summary | All Meals | - | {total_cal:.1f} kcal | {total_pro:.1f}g | {total_carb:.1f}g | {total_fat:.1f}g |"
  1372. lines.insert(table_end + 1, total_row)
  1373. return "\n".join(lines)
  1374. final_reply = add_total_row_to_markdown_table(clean_reply)
  1375. # Align numeric columns on the right
  1376. def align_markdown_table(text):
  1377. lines = text.split('\n')
  1378. for i, line in enumerate(lines):
  1379. line_s = line.strip()
  1380. if line_s.startswith('|') and line_s.endswith('|') and '---' in line_s:
  1381. parts = [p.strip() for p in line_s.strip('|').split('|')]
  1382. if len(parts) >= 7:
  1383. new_parts = []
  1384. for idx, part in enumerate(parts):
  1385. if idx < 3:
  1386. new_parts.append('---')
  1387. else:
  1388. new_parts.append('---:')
  1389. lines[i] = '| ' + ' | '.join(new_parts) + ' |'
  1390. break
  1391. return '\n'.join(lines)
  1392. final_reply = align_markdown_table(final_reply)
  1393. # PDF Generation
  1394. def generate_pdf(text):
  1395. import re
  1396. # Aggressive sanitization: if a table row has 4 columns and the last contains a comma or space before 'g', split it
  1397. sanitized_lines = []
  1398. for line in text.split('\n'):
  1399. line = line.strip()
  1400. if line.startswith('|') and line.endswith('|') and '---' not in line:
  1401. cols = [c.strip() for c in line.strip('|').split('|')]
  1402. # If exactly 4 columns and the last one contains calories and protein merged
  1403. if len(cols) == 4 and any(char.isdigit() for char in cols[3]):
  1404. # Attempt to split by comma or 'kcal'
  1405. if ',' in cols[3]:
  1406. split_last = cols[3].split(',', 1)
  1407. cols = cols[:3] + [split_last[0].strip(), split_last[1].strip()]
  1408. elif 'kcal' in cols[3].lower():
  1409. split_last = re.split(r'(?<=kcal)\s+', cols[3], flags=re.IGNORECASE, maxsplit=1)
  1410. if len(split_last) == 2:
  1411. cols = cols[:3] + [split_last[0].strip(), split_last[1].strip()]
  1412. sanitized_lines.append('| ' + ' | '.join(cols) + ' |')
  1413. else:
  1414. sanitized_lines.append(line)
  1415. text = '\n'.join(sanitized_lines)
  1416. pdf = FPDF()
  1417. pdf.add_page()
  1418. pdf.set_font("Helvetica", 'B', 16)
  1419. pdf.cell(0, 10, "Strict Clinical Meal Plan", new_x="LMARGIN", new_y="NEXT", align='C')
  1420. pdf.ln(h=5)
  1421. table_data = []
  1422. def flush_table():
  1423. if not table_data: return
  1424. pdf.set_font("Helvetica", size=9)
  1425. # Auto-calculate col_widths based on 5 columns if present
  1426. cw = (20, 40, 15, 10, 15) if len(table_data[0]) == 5 else (20, 30, 15, 10, 10, 10, 10) if len(table_data[0]) >= 7 else None
  1427. alignments = ("LEFT", "LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "RIGHT") if len(table_data[0]) >= 7 else ("LEFT", "LEFT", "LEFT", "RIGHT", "RIGHT") if len(table_data[0]) == 5 else "LEFT"
  1428. try:
  1429. with pdf.table(text_align=alignments, col_widths=cw) as table:
  1430. for row_data in table_data:
  1431. row = table.row()
  1432. for datum in row_data:
  1433. row.cell(str(datum).encode('latin-1', 'replace').decode('latin-1'))
  1434. except Exception as e:
  1435. pdf.multi_cell(0, 8, "Table Render Error: " + str(e))
  1436. table_data.clear()
  1437. pdf.ln(h=5)
  1438. for line in text.split('\n'):
  1439. line = line.strip()
  1440. if not line:
  1441. flush_table()
  1442. pdf.ln(h=2)
  1443. continue
  1444. if line.startswith('|') or ('|' in line and 'Total' in line):
  1445. if not line.endswith('|'): line += ' |'
  1446. if not line.startswith('|'): line = '| ' + line
  1447. if '---' in line: continue
  1448. cols = [col.strip() for col in line.strip('|').split('|')]
  1449. # Normalize column length to prevent FPDF table crashing
  1450. if table_data:
  1451. target_len = len(table_data[0])
  1452. while len(cols) < target_len: cols.append("")
  1453. cols = cols[:target_len]
  1454. table_data.append(cols)
  1455. else:
  1456. flush_table()
  1457. pdf.set_font("Helvetica", size=11)
  1458. clean_line = str(line).encode('latin-1', 'replace').decode('latin-1')
  1459. pdf.multi_cell(0, 8, clean_line)
  1460. flush_table()
  1461. current_dir = os.path.dirname(os.path.abspath(__file__))
  1462. tmp_dir = os.path.join(current_dir, "tmp")
  1463. os.makedirs(tmp_dir, exist_ok=True)
  1464. pdf_path = os.path.join(tmp_dir, "meal_plan.pdf")
  1465. pdf.output(pdf_path)
  1466. with open(pdf_path, "rb") as f:
  1467. return f.read()
  1468. st.session_state['generated_meal_plan'] = final_reply
  1469. st.session_state['meal_plan_pdf_data'] = generate_pdf(final_reply)
  1470. st.session_state['meal_plan_elapsed'] = time.time() - start_llm
  1471. st.rerun()
  1472. except Exception as e:
  1473. error_msg = str(e).lower()
  1474. if "404" in error_msg or "not found" in error_msg:
  1475. st.warning("⚠️ The AI engine is currently downloading its core models in the background. Please wait a minute and try again!")
  1476. else:
  1477. st.error(f"AI Generation Failed: {e}")
  1478. # Outside the button, render the session state if exists
  1479. if 'generated_meal_plan' in st.session_state:
  1480. st.info("🧠 AI is analyzing nutritional synergies and generating your plan...")
  1481. st.markdown(st.session_state['generated_meal_plan'])
  1482. if st.session_state.get('meal_plan_elapsed'):
  1483. st.caption(f"⏱️ Execution Trace: Module=Ollama, MySQL | Time={st.session_state['meal_plan_elapsed']:.2f} seconds")
  1484. st.download_button(
  1485. label="📄 Download PDF Export",
  1486. data=st.session_state['meal_plan_pdf_data'],
  1487. file_name="Clinical_Meal_Plan.pdf",
  1488. mime="application/pdf",
  1489. type="primary"
  1490. )
  1491. if conn_reader: conn_reader.close()