Ver código fonte

[#1] chore: resolve security leak, configure dynamic versioning filters, update Streamlit and Flask applications to read version from %cd, update unit converter, ingestion, and search features, and export Taiga scrum data

Lange François 3 semanas atrás
pai
commit
ea2783436b

+ 37 - 4
.gitattributes

@@ -1,5 +1,38 @@
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-*.py ident export-subst
-*.sh ident export-subst
-*.sql ident export-subst
-*.md ident export-subst
+* text=auto
+# # 1. Forcer le format de date requis YYYY/MM/DD HH:MM:SS
+# git config log.date "format:%Y/%m/%d %H:%M:%S"
+# 
+# # 2. Configurer le filtre "Smudge" (Injection lors du checkout / pull)
+# git config filter.ident-dynamic.smudge '
+#   proj=$(basename "$(git rev-parse --show-toplevel)");
+#   file="%f";
+#   perl -pe "s|\\\$Format:PROJECT_NAME:FILE_NAME:(.*?)\\\$|\\\$Format:\$proj:\$file:\$1\\\$|g" | \
+#   git archive --subst-vars | cat
+# '
+# 
+# # 3. Configurer le filtre "Clean" (Nettoyage avant le commit pour éviter les conflits)
+# # The LEFT PART OF THE PIPE MUST BE "$Format:PROJECT_NAME:FILE_NAME"
+# git config filter.ident-dynamic.clean '
+#   perl -pe "s|\\\$Format:[^:]+:[^:]+(:.*?)\\\$|\\\$Format:PROJECT_NAME:FILE_NAME\$1\\\$|g"
+# '
+# 
+# 1. Protection du script de filtre pour Unix/WSL
+# Force LF on checkout for all text files (prevents CRLF on Windows)
+* text eol=lf
+
+# Force CRLF and apply ident-dynamic filter for Windows batch files
+*.bat filter=ident-dynamic eol=crlf
+
+# Git filter configurations for dynamic identifiers
+git-ident-filter.py text eol=lf
+*.md filter=ident-dynamic
+*.py filter=ident-dynamic
+*.sh filter=ident-dynamic
+*.txt filter=ident-dynamic
+*.yml filter=ident-dynamic
+.env filter=ident-dynamic
+.env.example filter=ident-dynamic
+*.sql filter=ident-dynamic
+.gitignore filter=ident-dynamic
+Dockerfile filter=ident-dynamic

+ 120 - 35
app.py

@@ -2,7 +2,7 @@
 # $Id$
 # $Id$
 # $Author$
 # $Author$
 # $log$
 # $log$
-#ident "@(#)LocalFoodAI:app.py:$Format:%D:%ci:%cN:%h$"
+#ident "@(#)LocalFoodAI:app.py:$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 import streamlit as st
 import streamlit as st
 import extra_streamlit_components as stx
 import extra_streamlit_components as stx
@@ -361,6 +361,16 @@ def reset_password(username: str, email: str) -> Any:
     return False
     return False
 
 
 # UI Theming
 # UI Theming
+def is_valid_image_url(url):
+    if not url or not isinstance(url, str):
+        return False
+    url = url.strip()
+    if not url.startswith(('http://', 'https://')):
+        return False
+    if 'invalid' in url.lower():
+        return False
+    return True
+
 def reformat_git_date(date_str):
 def reformat_git_date(date_str):
     from datetime import datetime
     from datetime import datetime
     try:
     try:
@@ -376,35 +386,53 @@ def reformat_git_date(date_str):
 
 
 def render_version():
 def render_version():
     st.markdown("---")
     st.markdown("---")
-    try:
-        if os.path.exists('git_version.txt'):
-            with open('git_version.txt', 'r') as f: git_version = f.read().strip()
-        else:
-            git_version = subprocess.check_output(['git', 'describe', '--tags']).decode('utf-8').strip()
-    except Exception:
-        git_version = "v1.3.0"
-        
-    formatted_version = reformat_git_date(git_version)
-    st.caption(f"🚀 Version: {formatted_version}")
+    git_version = None
+    git_hash = None
     
     
+    # 1. Parse from the smudged ident header in app.py
     try:
     try:
-        if os.path.exists('git_id.txt'):
-            with open('git_id.txt', 'r') as f: git_id = f.read().strip()
-        else:
-            git_id = subprocess.check_output(['git', 'log', '-1', '--format=%cd %h', 'app.py']).decode('utf-8').strip()
+        current_dir = os.path.dirname(os.path.abspath(__file__))
+        app_path = os.path.join(current_dir, 'app.py')
+        if os.path.exists(app_path):
+            with open(app_path, 'r', encoding='utf-8') as f:
+                import re
+                for _ in range(15):
+                    line = f.readline()
+                    if not line:
+                        break
+                    if "$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$Format:LocalFoodAI:app\.py:(.*?)\$', line)
+                        if match:
+                            parts = match.group(1).split(':')
+                            if len(parts) >= 7 and not parts[0].startswith('%an'):
+                                git_version = parts[5] # %cd (committer date)
+                                git_hash = parts[6][:7] if parts[6] else ""
+                                break
     except Exception:
     except Exception:
-        git_id = "Unknown"
+        pass
         
         
-    parts = git_id.strip().split()
-    if len(parts) >= 6:
-        date_part = " ".join(parts[:6])
-        hash_part = parts[6] if len(parts) > 6 else ""
-        formatted_date = reformat_git_date(date_part)
-        formatted_id = f"{formatted_date} {hash_part}".strip()
-    else:
-        formatted_id = git_id
-        
-    st.caption(f"📅 Git ID: {formatted_id}")
+    # 2. Fallback using git log command
+    if not git_version or not git_hash:
+        try:
+            git_version = subprocess.check_output(
+                ['git', 'log', '-1', '--date=format:%Y/%m/%d %H:%M:%S', '--format=%cd', 'app.py'],
+                stderr=subprocess.DEVNULL
+            ).decode('utf-8').strip()
+            git_hash = subprocess.check_output(
+                ['git', 'log', '-1', '--format=%h', 'app.py'],
+                stderr=subprocess.DEVNULL
+            ).decode('utf-8').strip()
+        except Exception:
+            pass
+
+    # 3. Default fallback values
+    if not git_version:
+        git_version = "2026/06/11 08:26:59"
+    if not git_hash:
+        git_hash = "1701828"
+
+    st.caption(f"🚀 Version: {git_version}")
+    st.caption(f"📅 Git ID: {git_version} {git_hash}")
+    st.caption(f"Model: {ACTIVE_MODEL}")
 
 
 st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
 st.set_page_config(page_title="Food AI Explorer", page_icon="🍔", layout="wide")
 
 
@@ -672,12 +700,16 @@ with tab_explore:
                     l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
                     l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
                     query = f"""
                     query = f"""
                         SELECT c.code, c.product_name, c.generic_name, c.brands, c.ingredients_text,
                         SELECT c.code, c.product_name, c.generic_name, c.brands, c.ingredients_text,
+                               c.url, c.image_url, c.image_small_url, c.image_ingredients_url, 
+                               c.image_ingredients_small_url, c.image_nutrition_url, c.image_nutrition_small_url,
                                a.allergens,
                                a.allergens,
                                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,
                                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,
                                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`,
                                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`,
                                min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
                                min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
                         FROM (
                         FROM (
-                            SELECT code, product_name, generic_name, brands, ingredients_text
+                            SELECT code, product_name, generic_name, brands, ingredients_text,
+                                   url, image_url, image_small_url, image_ingredients_url, 
+                                   image_ingredients_small_url, image_nutrition_url, image_nutrition_small_url
                             FROM food_db.products_core
                             FROM food_db.products_core
                             WHERE (MATCH(product_name, ingredients_text) AGAINST(%s IN BOOLEAN MODE) OR product_name LIKE %s)
                             WHERE (MATCH(product_name, ingredients_text) AGAINST(%s IN BOOLEAN MODE) OR product_name LIKE %s)
                             AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
                             AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
@@ -699,7 +731,7 @@ with tab_explore:
                     cursor.execute(query, (sq_bool, sq_like, sq_bool, sq_bool, min_pro, min_fat, min_carb, max_sug))
                     cursor.execute(query, (sq_bool, sq_like, sq_bool, sq_bool, min_pro, min_fat, min_carb, max_sug))
                     results = cursor.fetchall()
                     results = cursor.fetchall()
                     elapsed = time.time() - start_time
                     elapsed = time.time() - start_time
-                    st.caption(f"⏱️ DB Query Executed in {elapsed:.3f} seconds")
+                    st.caption(f"⏱️ Execution Trace: Module=MySQL | Time={elapsed:.3f} seconds")
                     
                     
                     if results:
                     if results:
                         # Fetch EAV Medical Profile
                         # Fetch EAV Medical Profile
@@ -712,7 +744,7 @@ with tab_explore:
                         
                         
                         st.markdown("### 🛠️ Dynamic Column Display")
                         st.markdown("### 🛠️ Dynamic Column Display")
                         default_columns = [
                         default_columns = [
-                            'code', 'product_name', 'generic_name', 'brands', 'allergens', 'ingredients_text',
+                            'code', 'product_name', 'generic_name', 'brands', 'image_small_url', 'allergens', 'ingredients_text',
                             'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'sodium_100g', 'energy-kcal_100g',
                             'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'sodium_100g', 'energy-kcal_100g',
                             'vitamin-c_100g', 'iron_100g', 'calcium_100g'
                             'vitamin-c_100g', 'iron_100g', 'calcium_100g'
                         ]
                         ]
@@ -813,6 +845,12 @@ with tab_explore:
                             warnings_col.append(" | ".join(list(set(warns))) if warns else "✅ Safe for Profile")
                             warnings_col.append(" | ".join(list(set(warns))) if warns else "✅ Safe for Profile")
                             
                             
                         df_display.insert(0, 'Medical Warning', warnings_col)
                         df_display.insert(0, 'Medical Warning', warnings_col)
+                        # Clean image URLs in df_display before displaying
+                        for col in df_display.columns:
+                            if 'image' in col.lower():
+                                df_display[col] = df_display[col].apply(lambda x: x if is_valid_image_url(x) else "")
+                        # Replace None values with &nsbp
+                        df_display.replace(to_replace=r'^None$', value='&nsbp', regex=True, inplace=True)
                         # Only fillna with empty string on object columns to avoid Arrow float64 conversion errors
                         # Only fillna with empty string on object columns to avoid Arrow float64 conversion errors
                         for col in df_display.columns:
                         for col in df_display.columns:
                             if df_display[col].dtype == 'object':
                             if df_display[col].dtype == 'object':
@@ -820,18 +858,26 @@ with tab_explore:
                         df_display.index = range(1, len(df_display) + 1)
                         df_display.index = range(1, len(df_display) + 1)
                         styled_df = df_display.style.apply(highlight_medical_warnings, axis=1)
                         styled_df = df_display.style.apply(highlight_medical_warnings, axis=1)
 
 
+                        col_configs = {}
+                        for col in df_display.columns:
+                            if 'image' in col.lower():
+                                col_configs[col] = st.column_config.ImageColumn(col.replace('_', ' ').title())
+
                         st.success(f"Analysed {len(results)} records utilizing dynamic Partitions!")
                         st.success(f"Analysed {len(results)} records utilizing dynamic Partitions!")
-                        st.dataframe(styled_df, use_container_width=True, hide_index=True)
+                        st.dataframe(styled_df, column_config=col_configs, use_container_width=True, hide_index=True)
                         
                         
                         if st.button("🤖 Ask AI to Evaluate This Table"):
                         if st.button("🤖 Ask AI to Evaluate This Table"):
                             with st.spinner("AI is dynamically evaluating these records against your profile..."):
                             with st.spinner("AI is dynamically evaluating these records against your profile..."):
                                 user_eav = get_eav_profile(st.session_state["authenticated_user"])
                                 user_eav = get_eav_profile(st.session_state["authenticated_user"])
                                 profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
                                 profile_text = ", ".join([f"{p['name']}: {p['value']}" for p in user_eav]) if user_eav else "None"
+                                start_eval = time.time()
                                 minimal_records = df_display[['product_name', 'Medical Warning']].head(10).to_dict('records')
                                 minimal_records = df_display[['product_name', 'Medical Warning']].head(10).to_dict('records')
-                                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}. Provide a direct, readable clinical summary. Do not output raw JSON."
+                                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."
                                 try:
                                 try:
                                     response = ollama.chat(model=ACTIVE_MODEL, messages=[{'role': 'user', 'content': eval_prompt}], stream=True)
                                     response = ollama.chat(model=ACTIVE_MODEL, messages=[{'role': 'user', 'content': eval_prompt}], stream=True)
                                     st.write_stream(chunk['message']['content'] for chunk in response)
                                     st.write_stream(chunk['message']['content'] for chunk in response)
+                                    elapsed_eval = time.time() - start_eval
+                                    st.caption(f"⏱️ Execution Trace: Module=Ollama | Time={elapsed_eval:.2f} seconds")
                                 except Exception as e:
                                 except Exception as e:
                                     error_msg = str(e).lower()
                                     error_msg = str(e).lower()
                                     if "404" in error_msg or "not found" in error_msg:
                                     if "404" in error_msg or "not found" in error_msg:
@@ -995,6 +1041,7 @@ with tab_plate:
                     col_scope, col_comp = st.columns(2)
                     col_scope, col_comp = st.columns(2)
                     search_scope = col_scope.radio("Search Scope", ["Auto (Cascaded)", "Product Name Only", "Both (Product & Ingredients)", "Ingredients Only"], horizontal=True)
                     search_scope = col_scope.radio("Search Scope", ["Auto (Cascaded)", "Product Name Only", "Both (Product & Ingredients)", "Ingredients Only"], horizontal=True)
                     comp_reqs = col_comp.multiselect("Require Nutrients (Sorts by highest)", ["Iron", "Vitamin C", "Calcium", "Proteins", "Fiber"])
                     comp_reqs = col_comp.multiselect("Require Nutrients (Sorts by highest)", ["Iron", "Vitamin C", "Calcium", "Proteins", "Fiber"])
+                    raw_ingredient_filter = col_scope.radio("Raw Ingredient Only?", ["No", "Yes"], horizontal=True)
                     
                     
                     submit_add_search = st.form_submit_button("Search Food")
                     submit_add_search = st.form_submit_button("Search Food")
                 
                 
@@ -1021,13 +1068,18 @@ with tab_plate:
                         wh_comp = " AND " + " AND ".join(r_clauses) if r_clauses else ""
                         wh_comp = " AND " + " AND ".join(r_clauses) if r_clauses else ""
                         order_by = "ORDER BY " + ", ".join(o_clauses) if o_clauses else ""
                         order_by = "ORDER BY " + ", ".join(o_clauses) if o_clauses else ""
                         
                         
+                        raw_clause = ""
+                        if raw_ingredient_filter == "Yes":
+                            raw_clause = "AND (image_ingredients_url IS NULL OR image_ingredients_url = '') AND (image_ingredients_small_url IS NULL OR image_ingredients_small_url = '')"
+                        
                         sql = f"""
                         sql = f"""
-                            SELECT c.code, c.product_name
+                            SELECT c.code, c.product_name, c.image_small_url, c.image_ingredients_small_url, c.image_nutrition_small_url
                             FROM (
                             FROM (
-                                SELECT code, product_name
+                                SELECT code, product_name, image_small_url, image_ingredients_small_url, image_nutrition_small_url
                                 FROM food_db.products_core
                                 FROM food_db.products_core
                                 WHERE MATCH({m_col}) AGAINST(%s IN BOOLEAN MODE)
                                 WHERE MATCH({m_col}) AGAINST(%s IN BOOLEAN MODE)
                                 AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
                                 AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
+                                {raw_clause}
                                 ORDER BY LENGTH(product_name) ASC
                                 ORDER BY LENGTH(product_name) ASC
                             ) c
                             ) c
                             JOIN food_db.products_macros m ON c.code = m.code
                             JOIN food_db.products_macros m ON c.code = m.code
@@ -1047,11 +1099,36 @@ with tab_plate:
                         search_res = execute_search("ingredients_text")
                         search_res = execute_search("ingredients_text")
                         
                         
                     elapsed = time.time() - start_time
                     elapsed = time.time() - start_time
-                    st.caption(f"⏱️ Plate Search Executed in {elapsed:.3f} seconds")
+                    st.caption(f"⏱️ Execution Trace: Module=MySQL | Time={elapsed:.3f} seconds")
                     st.session_state['plate_search_res'] = search_res
                     st.session_state['plate_search_res'] = search_res
 
 
                 if st.session_state.get('plate_search_res'):
                 if st.session_state.get('plate_search_res'):
                     search_res = st.session_state['plate_search_res']
                     search_res = st.session_state['plate_search_res']
+                    
+                    # Select Product Table Gallery
+                    st.markdown("##### 🔍 Found Products Preview")
+                    df_rows = []
+                    for r in search_res:
+                        df_rows.append({
+                            "Code": r['code'],
+                            "Product Name": r['product_name'],
+                            "Image": r.get('image_small_url') if is_valid_image_url(r.get('image_small_url')) else "",
+                            "Ingredients Image": r.get('image_ingredients_small_url') if is_valid_image_url(r.get('image_ingredients_small_url')) else "",
+                            "Nutrition Image": r.get('image_nutrition_small_url') if is_valid_image_url(r.get('image_nutrition_small_url')) else "",
+                        })
+                    gallery_df = pd.DataFrame(df_rows)
+                    gallery_df.replace(to_replace=r'^None$', value='&nsbp', regex=True, inplace=True)
+                    st.dataframe(
+                        gallery_df,
+                        column_config={
+                            "Image": st.column_config.ImageColumn("Image"),
+                            "Ingredients Image": st.column_config.ImageColumn("Ingredients"),
+                            "Nutrition Image": st.column_config.ImageColumn("Nutrition"),
+                        },
+                        use_container_width=True,
+                        hide_index=True
+                    )
+                    
                     options = {f"{r['product_name']} ({r['code']})": r for r in search_res}
                     options = {f"{r['product_name']} ({r['code']})": r for r in search_res}
                     selected_str = st.selectbox("Select Product", list(options.keys()))
                     selected_str = st.selectbox("Select Product", list(options.keys()))
                     selected_product = options[selected_str]
                     selected_product = options[selected_str]
@@ -1059,6 +1136,7 @@ with tab_plate:
                     add_amount_str = st.text_input("Portion Quantity (e.g., '100g', '2 tbsp', '1.5 cups', '1 pinch')", value="100g")
                     add_amount_str = st.text_input("Portion Quantity (e.g., '100g', '2 tbsp', '1.5 cups', '1 pinch')", value="100g")
                     
                     
                     if st.button("Add Item to Plate"):
                     if st.button("Add Item to Plate"):
+                        start_add = time.time()
                         # Use UnitConverter to parse
                         # Use UnitConverter to parse
                         grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
                         grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
                         if grams is not None:
                         if grams is not None:
@@ -1066,6 +1144,8 @@ with tab_plate:
                                           (active_p_id, selected_product['code'], grams))
                                           (active_p_id, selected_product['code'], grams))
                             conn.commit()
                             conn.commit()
                             st.success(f"Added {grams}g of {selected_product['product_name']}!")
                             st.success(f"Added {grams}g of {selected_product['product_name']}!")
+                            elapsed_add = time.time() - start_add
+                            st.caption(f"⏱️ Execution Trace: Module=UnitConverter, MySQL | Time={elapsed_add:.3f} seconds")
                             st.session_state.pop('plate_search_res', None)
                             st.session_state.pop('plate_search_res', None)
                             st.rerun()
                             st.rerun()
                         else:
                         else:
@@ -1110,6 +1190,11 @@ with tab_planner:
             Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
             Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
             Health profile: {profile_text}. 
             Health profile: {profile_text}. 
             
             
+            CARBOHYDRATE PRECISION & NO HALLUCINATION:
+            - The customer is extremely interested in carbohydrates, so you MUST be very precise. 
+            - Under no circumstances should you hallucinate any nutritional values. No hallucinations. 
+            - Base all calculations and values strictly on the database context provided: {db_context}.
+            
             COGNITIVE SCRATCHPAD INSTRUCTIONS:
             COGNITIVE SCRATCHPAD INSTRUCTIONS:
             - 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.
             - 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.
             - Format:
             - Format:
@@ -1145,7 +1230,7 @@ with tab_planner:
                 clean_stream = filter_scratchpad_stream(response, raw_chunks)
                 clean_stream = filter_scratchpad_stream(response, raw_chunks)
                 ai_reply = st.write_stream(clean_stream)
                 ai_reply = st.write_stream(clean_stream)
                 raw_reply = "".join(raw_chunks)
                 raw_reply = "".join(raw_chunks)
-                st.caption(f"⏱️ AI Meal Plan generated in {time.time() - start_llm:.2f} seconds")
+                st.caption(f"⏱️ Execution Trace: Module=Ollama, MySQL | Time={time.time() - start_llm:.2f} seconds")
                 
                 
                 # PDF Generation
                 # PDF Generation
                 def generate_pdf(text):
                 def generate_pdf(text):

+ 2 - 4
docs/Backup_Procedure.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Database Backup and Restore Procedure
 # Database Backup and Restore Procedure
 
 
 ## 1. Overview & Policy
 ## 1. Overview & Policy
@@ -77,4 +75,4 @@ Expected result: A count of OpenFoodFacts entries (typically > 10,000 records).
 Operators must verify the backup archive integrity weekly:
 Operators must verify the backup archive integrity weekly:
 1. Copy the `.gz` backup to a local testing workspace.
 1. Copy the `.gz` backup to a local testing workspace.
 2. Run `gzip -t backups/filename.sql.gz` to ensure the archive is not corrupted.
 2. Run `gzip -t backups/filename.sql.gz` to ensure the archive is not corrupted.
-3. Test restoring to a local fallback container instance to verify data accessibility.
+3. Test restoring to a local fallback container instance to verify data accessibility.

+ 2 - 4
docs/Data_Ingestion.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Data Ingestion Pipeline
 # Data Ingestion Pipeline
 
 
 ## Overview
 ## Overview
@@ -10,4 +8,4 @@ The application utilizes `data_sync.sh` to update the OpenFoodFacts dataset.
 Run `bash data_sync.sh --online`. The script will download the latest CSV directly from the official servers and trigger the ingestion pipeline.
 Run `bash data_sync.sh --online`. The script will download the latest CSV directly from the official servers and trigger the ingestion pipeline.
 
 
 ## Offline Mode
 ## Offline Mode
-Drop a `en.openfoodfacts.org.products.csv` file into the `/data` folder and run `bash data_sync.sh`. The script detects the file and triggers the Docker ingestion container.
+Drop a `en.openfoodfacts.org.products.csv` file into the `/data` folder and run `bash data_sync.sh`. The script detects the file and triggers the Docker ingestion container.

+ 3 - 5
docs/Final_Report.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Final Project Report (Living Document)
 # Final Project Report (Living Document)
 
 
 ## What Has Been Done
 ## What Has Been Done
@@ -8,7 +6,7 @@ The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%c
 2. **Database Optimization**: Successfully loaded OpenFoodFacts records and utilized advanced vertical partitioning and FULLTEXT indices.
 2. **Database Optimization**: Successfully loaded OpenFoodFacts records and utilized advanced vertical partitioning and FULLTEXT indices.
 3. **Clinical Subquery Strategy**: Refactored the core Pandas/SQL query pipeline to use subquery limiting, resolving Cartesian join explosions and reducing query latency to ~0.04s.
 3. **Clinical Subquery Strategy**: Refactored the core Pandas/SQL query pipeline to use subquery limiting, resolving Cartesian join explosions and reducing query latency to ~0.04s.
 4. **Monitoring & Security**: Nginx securely proxies traffic on Port 80. Zabbix actively monitors proxy and server health, dynamically handling SNMP/alert loops in local/offline fallback mode.
 4. **Monitoring & Security**: Nginx securely proxies traffic on Port 80. Zabbix actively monitors proxy and server health, dynamically handling SNMP/alert loops in local/offline fallback mode.
-5. **Git Versioning**: Implemented Git `.gitattributes` to push `$Id$` tracking directly into the Python Application UI.
+5. **Git Versioning**: Implemented Git `.gitattributes` to push `$Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $` tracking directly into the Python Application UI.
 
 
 ## What Needs To Be Done (Day 2 Operations)
 ## What Needs To Be Done (Day 2 Operations)
 1. **SSL/TLS Certificates**: The Nginx proxy is functional on HTTP port 80. Port 443 (HTTPS) must be configured with a Let's Encrypt certificate for true production encryption.
 1. **SSL/TLS Certificates**: The Nginx proxy is functional on HTTP port 80. Port 443 (HTTPS) must be configured with a Let's Encrypt certificate for true production encryption.
@@ -18,4 +16,4 @@ The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%c
 ## What Is The Next Step
 ## What Is The Next Step
 - Execute the `data_sync.sh` cron job monthly.
 - Execute the `data_sync.sh` cron job monthly.
 - Maintain the automated `backup_db.sh` 7-day retention cycle.
 - Maintain the automated `backup_db.sh` 7-day retention cycle.
-- Begin the hand-off to the operational team for Phase 2 feature requests.
+- Begin the hand-off to the operational team for Phase 2 feature requests.

+ 2 - 4
docs/Installation_Guide.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Installation Guide
 # Installation Guide
 
 
 ## Requirements
 ## Requirements
@@ -17,4 +15,4 @@ The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%c
 4. **Deploy Stack**:
 4. **Deploy Stack**:
    - For regular production: `docker compose up -d --build`
    - For regular production: `docker compose up -d --build`
    - For local/offline single-node fallback: `docker compose -f docker-compose_skip.yml up -d`
    - For local/offline single-node fallback: `docker compose -f docker-compose_skip.yml up -d`
-5. Navigate to `http://localhost` (or `http://localhost:8502` for direct Streamlit port)
+5. Navigate to `http://localhost` (or `http://localhost:8502` for direct Streamlit port)

+ 3 - 5
docs/Operator_Installation_Guide.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Local Food AI - Detailed Operator Installation Guide
 # Local Food AI - Detailed Operator Installation Guide
 
 
 This document is a step-by-step installation, mapping, configuration, and verification manual for deploying the **Local Food AI** system in an enterprise environment. It covers hybrid hypervisor infrastructure (WSL2, Hyper-V, and VirtualBox), cross-node networking, SNMPv3 monitoring, alert channels, and acceptance testing.
 This document is a step-by-step installation, mapping, configuration, and verification manual for deploying the **Local Food AI** system in an enterprise environment. It covers hybrid hypervisor infrastructure (WSL2, Hyper-V, and VirtualBox), cross-node networking, SNMPv3 monitoring, alert channels, and acceptance testing.
@@ -181,6 +179,6 @@ Run these test cases to verify the installation:
 | :--- | :--- | :--- | :---: |
 | :--- | :--- | :--- | :---: |
 | **TC-OP-01** | Search 'Cheese' on Search Tab | 10+ records returned in <0.04s. Listeria warning flags on unpasteurized. | `[ ]` |
 | **TC-OP-01** | Search 'Cheese' on Search Tab | 10+ records returned in <0.04s. Listeria warning flags on unpasteurized. | `[ ]` |
 | **TC-OP-02** | Enter '1.5 cups' in Plate Tab | Parsed and converted to metric grams based on density index. | `[ ]` |
 | **TC-OP-02** | Enter '1.5 cups' in Plate Tab | Parsed and converted to metric grams based on density index. | `[ ]` |
-| **TC-OP-03** | Ask Chat: 'Can I eat sushi?' | Qwen2.5:1.5b retrieves database context and flags raw fish as forbidden for pregnancy. | `[ ]` |
+| **TC-OP-03** | Ask Chat: 'Can I eat sushi?' | llama3.2-vision:11b retrieves database context and flags raw fish as forbidden for pregnancy. | `[ ]` |
 | **TC-OP-04** | Trigger manual db backup | Timestamped compressed .sql.gz created inside backups/ folder. | `[ ]` |
 | **TC-OP-04** | Trigger manual db backup | Timestamped compressed .sql.gz created inside backups/ folder. | `[ ]` |
-| **TC-OP-05** | Terminate Ollama Container | Zabbix PROBLEM active alert generated on dashboard in < 30 seconds. | `[ ]` |
+| **TC-OP-05** | Terminate Ollama Container | Zabbix PROBLEM active alert generated on dashboard in < 30 seconds. | `[ ]` |

+ 2 - 4
docs/Scrum_Artifacts.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Scrum Artifacts
 # Scrum Artifacts
-Contains User Stories, velocity tracking, and burndown charts from Taiga.
+Contains User Stories, velocity tracking, and burndown charts from Taiga.

+ 2 - 4
docs/Scrum_Daily.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Daily Scrums
 # Daily Scrums
-- **26.05.07 DAILY**: Fixed time scope bug, added Nginx proxy, built sync scripts.
+- **26.05.07 DAILY**: Fixed time scope bug, added Nginx proxy, built sync scripts.

+ 2 - 4
docs/Scrum_Plan.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Sprint Plans
 # Sprint Plans
-- **Sprint 10 PLAN**: Fix LLM Tool Calling, optimize Cartesian SQL explosion, build Teams webhooks.
+- **Sprint 10 PLAN**: Fix LLM Tool Calling, optimize Cartesian SQL explosion, build Teams webhooks.

+ 2 - 4
docs/Scrum_Retro.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Sprint Retrospectives
 # Sprint Retrospectives
-- **Sprint 10 RETROSPECTIVE**: Mitigated dirty data duplicates using SQL `GROUP BY`. Need to maintain strict Git commit tagging (`TG-XXX`).
+- **Sprint 10 RETROSPECTIVE**: Mitigated dirty data duplicates using SQL `GROUP BY`. Need to maintain strict Git commit tagging (`TG-XXX`).

+ 2 - 4
docs/Scrum_Review.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Sprint Reviews
 # Sprint Reviews
-- **Sprint 10 REVIEW**: App executes sub-second searches. Nginx fully operational on Port 80.
+- **Sprint 10 REVIEW**: App executes sub-second searches. Nginx fully operational on Port 80.

+ 2 - 4
docs/Scrum_Wiki.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Scrum Wiki Master List & Index Portal
 # Scrum Wiki Master List & Index Portal
 
 
 Welcome to the static Scrum documentation portal. This master wiki aggregates and organizes all daily stand-up logs, planning reports, retrospectives, reviews, and velocity charts recorded during the agile development of the **Local Food AI** clinical dietetics engine.
 Welcome to the static Scrum documentation portal. This master wiki aggregates and organizes all daily stand-up logs, planning reports, retrospectives, reviews, and velocity charts recorded during the agile development of the **Local Food AI** clinical dietetics engine.
@@ -34,4 +32,4 @@ Welcome to the static Scrum documentation portal. This master wiki aggregates an
 ---
 ---
 
 
 > [!NOTE]
 > [!NOTE]
-> **Operational Compliance**: All Scrum files above are synchronized with their respective Taiga milestone identifiers (`Sprint 13` and `Sprint 7`). All physical activities recorded in these markdown logs have corresponding closed tasks inside Taiga.
+> **Operational Compliance**: All Scrum files above are synchronized with their respective Taiga milestone identifiers (`Sprint 13` and `Sprint 7`). All physical activities recorded in these markdown logs have corresponding closed tasks inside Taiga.

+ 2 - 4
docs/Start_Stop_Procedures.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Infrastructure Stop & Start Operational Procedures
 # Infrastructure Stop & Start Operational Procedures
 
 
 This runbook outlines the exact sequence and commands to start, stop, and verify each microservice in the Local Food AI environment.
 This runbook outlines the exact sequence and commands to start, stop, and verify each microservice in the Local Food AI environment.
@@ -89,4 +87,4 @@ docker compose logs --tail=100
 
 
 # Verify TCP socket listener binds
 # Verify TCP socket listener binds
 netstat -tulpn | grep -E "80|3307|8081|11434"
 netstat -tulpn | grep -E "80|3307|8081|11434"
-```
+```

+ 2 - 4
docs/Test_Cases_Sprint8.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Sprint 8 Legacy Test Cases
 # Sprint 8 Legacy Test Cases
 - Tested RAG AI tool integration.
 - Tested RAG AI tool integration.
-- Tested user authentication flows.
+- Tested user authentication flows.

+ 2 - 4
docs/User_Description.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Local Food AI - User Description & Functional Guide
 # Local Food AI - User Description & Functional Guide
 
 
 ## 1. System Vision
 ## 1. System Vision
@@ -40,4 +38,4 @@ An automated clinical diet planner.
 ## 3. Supported Health & Medical Profiles
 ## 3. Supported Health & Medical Profiles
 - **Conditions**: Pregnant, Breastfeeding, Low Fat, Osteoporosis.
 - **Conditions**: Pregnant, Breastfeeding, Low Fat, Osteoporosis.
 - **Illnesses**: Diabetes, Hypertension, Kidney Disease, Scurvy, Anemia.
 - **Illnesses**: Diabetes, Hypertension, Kidney Disease, Scurvy, Anemia.
-- **Diets**: Vegan, Vegetarian, Kosher, Halal, Keto, Paleo, Christian (Lent/Good Friday).
+- **Diets**: Vegan, Vegetarian, Kosher, Halal, Keto, Paleo, Christian (Lent/Good Friday).

+ 2 - 4
docs/User_Guide.md

@@ -1,6 +1,4 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # User Guide
 # User Guide
 
 
 ## 1. Clinical Data Search
 ## 1. Clinical Data Search
@@ -10,4 +8,4 @@ Search for products using keywords. The system utilizes FULLTEXT matching to ins
 Add portion sizes of different foods to calculate cumulative nutritional intake. Use the 🗑️ icon to remove items.
 Add portion sizes of different foods to calculate cumulative nutritional intake. Use the 🗑️ icon to remove items.
 
 
 ## 3. Chat with AI
 ## 3. Chat with AI
-Ask the `qwen2.5:7b` model complex dietary questions. It natively utilizes RAG Tool Calling to silently search the database and formulate clinical answers.
+Ask the `qwen2.5:7b` model complex dietary questions. It natively utilizes RAG Tool Calling to silently search the database and formulate clinical answers.

+ 2 - 4
docs/WSL_Deployment.md

@@ -1,7 +1,5 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # WSL Deployment Runbook
 # WSL Deployment Runbook
 To deploy on Windows Subsystem for Linux:
 To deploy on Windows Subsystem for Linux:
 1. Ensure WSL2 backend is enabled in Docker Desktop.
 1. Ensure WSL2 backend is enabled in Docker Desktop.
-2. Follow standard Installation Guide inside the WSL Ubuntu terminal.
+2. Follow standard Installation Guide inside the WSL Ubuntu terminal.

+ 2 - 4
docs/Wiki_Home.md

@@ -1,5 +1,3 @@
-The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-
-# $Id$
+# $Id: 1701828b122e0c319e59134ca6511a42ecad9297 Lange François lanfr144@school.lu 2026/06/11 08:26:59 Lange François lanfr144@school.lu 2026/06/11 08:26:59   [TG-131] Purge database passwords from tracked files and format application versioning [PreRelease-1.0-26-g1701828] $
 # Documentation Home
 # Documentation Home
-Welcome to the static documentation mirror. Please navigate the markdown files in this directory for architectural diagrams and guides.
+Welcome to the static documentation mirror. Please navigate the markdown files in this directory for architectural diagrams and guides.

+ 394 - 0
docs/taiga_export.json

@@ -0,0 +1,394 @@
+{
+    "project_id": "21",
+    "sprints": [
+        {
+            "id": 83,
+            "name": "Sprint 8",
+            "estimated_start": "2026-06-04",
+            "estimated_finish": "2026-06-10",
+            "closed": true
+        },
+        {
+            "id": 82,
+            "name": "Sprint 7",
+            "estimated_start": "2026-05-28",
+            "estimated_finish": "2026-06-03",
+            "closed": true
+        },
+        {
+            "id": 73,
+            "name": "Sprint 6",
+            "estimated_start": "2026-05-21",
+            "estimated_finish": "2026-05-28",
+            "closed": true
+        },
+        {
+            "id": 89,
+            "name": "Sprint 7: Production Hardening & Handover",
+            "estimated_start": "2026-05-18",
+            "estimated_finish": "2026-05-20",
+            "closed": true
+        },
+        {
+            "id": 72,
+            "name": "Sprint 5",
+            "estimated_start": "2026-05-14",
+            "estimated_finish": "2026-05-21",
+            "closed": true
+        },
+        {
+            "id": 88,
+            "name": "Sprint 13",
+            "estimated_start": "2026-05-12",
+            "estimated_finish": "2026-05-19",
+            "closed": true
+        },
+        {
+            "id": 87,
+            "name": "Sprint 12",
+            "estimated_start": "2026-05-11",
+            "estimated_finish": "2026-05-18",
+            "closed": true
+        },
+        {
+            "id": 86,
+            "name": "Sprint 11",
+            "estimated_start": "2026-05-08",
+            "estimated_finish": "2026-05-15",
+            "closed": true
+        },
+        {
+            "id": 71,
+            "name": "Sprint 4",
+            "estimated_start": "2026-05-07",
+            "estimated_finish": "2026-05-14",
+            "closed": true
+        },
+        {
+            "id": 85,
+            "name": "Sprint 10",
+            "estimated_start": "2026-05-06",
+            "estimated_finish": "2026-05-13",
+            "closed": true
+        },
+        {
+            "id": 84,
+            "name": "Sprint 9",
+            "estimated_start": "2026-05-04",
+            "estimated_finish": "2026-05-04",
+            "closed": true
+        },
+        {
+            "id": 70,
+            "name": "Sprint 3",
+            "estimated_start": "2026-04-30",
+            "estimated_finish": "2026-05-07",
+            "closed": true
+        },
+        {
+            "id": 69,
+            "name": "Sprint 2",
+            "estimated_start": "2026-04-23",
+            "estimated_finish": "2026-04-30",
+            "closed": true
+        },
+        {
+            "id": 68,
+            "name": "Sprint 1",
+            "estimated_start": "2026-04-16",
+            "estimated_finish": "2026-04-23",
+            "closed": true
+        }
+    ],
+    "user_stories": [
+        {
+            "id": 204,
+            "subject": "US-10: Public Git repository with easy cloning support (LocalFoodAI_lanfr144)",
+            "status_id": 125,
+            "is_closed": false,
+            "milestone": 73
+        },
+        {
+            "id": 207,
+            "subject": "US-9: 100% local data privacy (no user data leaves the server)",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 206,
+            "subject": "US-1: Create an account and log in securely",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 208,
+            "subject": "US-2: Get complete nutritional value information on any food",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 209,
+            "subject": "US-4: Search for specific nutrient content and get a sortable list of all foods",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 211,
+            "subject": "US-5: Store food combinations in named and editable lists",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 212,
+            "subject": "US-11: Local hardware boundary containment on Ubuntu 24.04 VM",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 210,
+            "subject": "US-3: Get the full nutritional value overview for a given food combination",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 213,
+            "subject": "US-7: Freely chat about anything related to nutrition and get competent answers",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 214,
+            "subject": "US-6: Get menu proposals based on nutritional value and health constraints",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        },
+        {
+            "id": 215,
+            "subject": "US-8: Anonymous private web search tool (SearXNG) integration",
+            "status_id": 125,
+            "is_closed": true,
+            "milestone": 71
+        }
+    ],
+    "tasks": [
+        {
+            "id": 435,
+            "subject": "Rebuild setup_db.py to allow dynamic Pandas table generation.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 436,
+            "subject": "Update ingest_csv.py with to_sql and post-load index generating.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 437,
+            "subject": "Create start_batch_ingest.sh wrapper for disconnected execution.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 438,
+            "subject": "Configure server .forward mail protocols for centralized admin support.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 439,
+            "subject": "Create setup_searxng.sh to install Docker and bind anonymous SearXNG to localhost:8080.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 215
+        },
+        {
+            "id": 440,
+            "subject": "Update deploy.sh to include requests connectivity dependency.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 215
+        },
+        {
+            "id": 441,
+            "subject": "Rework app.py LLM inference loop to support native Mistral Tool/Function calling integrations.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 215
+        },
+        {
+            "id": 442,
+            "subject": "Why: Applying the global CSS architecture is the direct prerequisite to making the visual information actually look premium and readable when the user views the data.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 443,
+            "subject": "Why: Building the numerical filtering sliders logically completes the \"Advanced Search\" capabilities explicitly defined by this story.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 209
+        },
+        {
+            "id": 445,
+            "subject": "Why: Generating the Pandas calculation logic that mathematically adds up the macros is what delivers the final \"Combined Value Overview\" to the user!",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 210
+        },
+        {
+            "id": 446,
+            "subject": "Why: The core of this story is storing data, which is entirely solved by creating the explicit relational plates and plate_items MySQL database tables.",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 211
+        },
+        {
+            "id": 447,
+            "subject": "Implement EAV Mapping Database Architecture",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 214
+        },
+        {
+            "id": 448,
+            "subject": "Fix Windows Encodings in Pandas Ingestion Engine",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 208
+        },
+        {
+            "id": 449,
+            "subject": "Build Dynamic 'Medical Profile' CRUD Interface",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 214
+        },
+        {
+            "id": 450,
+            "subject": "Deploy Clinical Health-Warning Alert Engine",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 214
+        },
+        {
+            "id": 451,
+            "subject": "Deploy Email Resets and Persistent Query Limits",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 214
+        },
+        {
+            "id": 452,
+            "subject": "Create unified PDF presentation for review",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 214
+        },
+        {
+            "id": 453,
+            "subject": "Execute Alembic Database Migration scripting",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 454,
+            "subject": "Sanitize Ollama Mistral LLM endpoints on .170",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 455,
+            "subject": "Perform Green Recommendation Engine Demo",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 546,
+            "subject": "Auto‑generated task (define details)",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 204
+        },
+        {
+            "id": 547,
+            "subject": "Auto‑generated task (define details)",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 548,
+            "subject": "Execute: Zabbix Server Docker Setup",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 549,
+            "subject": "Execute: SNMPv3 Integration",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 550,
+            "subject": "Execute: Application Component Traps",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 551,
+            "subject": "Execute: Clinical Explorer Verification Testing",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 552,
+            "subject": "Execute: Zabbix Application Monitoring Checks",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 553,
+            "subject": "Execute: Zabbix Email Integration",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 554,
+            "subject": "Execute: Zabbix Live Alert Testing",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        },
+        {
+            "id": 555,
+            "subject": "Execute: Server Backup Procedures",
+            "status_id": 104,
+            "is_closed": true,
+            "user_story": 212
+        }
+    ]
+}

+ 0 - 2
git_id.txt

@@ -1,2 +0,0 @@
-#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-Unknown

+ 0 - 2
git_version.txt

@@ -1,2 +0,0 @@
-#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
-v1.3.0

+ 53 - 1
ingest_csv.py

@@ -47,13 +47,38 @@ def ingest_file(filename, engine):
 
 
     # Define the groupings
     # Define the groupings
     groups = {
     groups = {
-        'products_core': ['code', 'product_name', 'generic_name', 'brands', 'ingredients_text'],
+        'products_core': [
+            'code', 'product_name', 'generic_name', 'brands', 'ingredients_text',
+            'url', 'image_url', 'image_small_url', 'image_ingredients_url', 
+            'image_ingredients_small_url', 'image_nutrition_url', 'image_nutrition_small_url'
+        ],
         'products_allergens': ['code', 'allergens'],
         'products_allergens': ['code', 'allergens'],
         'products_macros': ['code', 'energy-kcal_100g', 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'fiber_100g', 'sodium_100g', 'salt_100g', 'cholesterol_100g'],
         'products_macros': ['code', 'energy-kcal_100g', 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'fiber_100g', 'sodium_100g', 'salt_100g', 'cholesterol_100g'],
         'products_vitamins': ['code', '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'],
         'products_vitamins': ['code', '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'],
         'products_minerals': ['code', 'calcium_100g', 'iron_100g', 'magnesium_100g', 'potassium_100g', 'zinc_100g']
         'products_minerals': ['code', 'calcium_100g', 'iron_100g', 'magnesium_100g', 'potassium_100g', 'zinc_100g']
     }
     }
 
 
+    # Schema Auto-Migration: check if columns mismatch, and drop table for clean recreation
+    try:
+        with engine.connect() as conn:
+            for table_name, columns in groups.items():
+                try:
+                    res = conn.execute(text(f"DESCRIBE {table_name}"))
+                    existing_cols = [row[0] for row in res.fetchall()]
+                    mismatch = False
+                    for col in columns:
+                        if col not in existing_cols:
+                            mismatch = True
+                            break
+                    if mismatch:
+                        print(f"⚠️ Columns mismatch for table {table_name}. Dropping table for recreation.")
+                        conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
+                        conn.commit()
+                except Exception:
+                    pass
+    except Exception as e:
+        print(f"Warning: Could not connect to database for schema check: {e}")
+
     # Pre-calculate what to read
     # Pre-calculate what to read
     all_required_cols = list(set([col for cols in groups.values() for col in cols]))
     all_required_cols = list(set([col for cols in groups.values() for col in cols]))
 
 
@@ -68,6 +93,33 @@ def ingest_file(filename, engine):
                 continue
                 continue
             df = chunk.dropna(subset=['code']).drop_duplicates(subset=['code']).copy()
             df = chunk.dropna(subset=['code']).drop_duplicates(subset=['code']).copy()
             
             
+            # Clean and consolidate CSV data
+            # 1. Clean code (must be numeric digits/characters, strip whitespace)
+            df['code'] = df['code'].astype(str).str.strip()
+            df = df[df['code'] != '']
+            
+            # 2. Clean product_name: strip whitespace, fill empty with generic name or brand, or skip if completely empty
+            if 'product_name' in df.columns:
+                df['product_name'] = df['product_name'].astype(str).str.strip()
+                if 'generic_name' in df.columns:
+                    df['product_name'] = df['product_name'].fillna(df['generic_name'].astype(str).str.strip())
+                if 'brands' in df.columns:
+                    df['product_name'] = df['product_name'].fillna(df['brands'].astype(str).str.strip())
+                # Replace string representations of "nan" / "none"
+                df['product_name'] = df['product_name'].replace(['nan', 'NaN', 'None', 'none', ''], None)
+                
+            # 3. Drop rows with missing or null product_name (consolidate only valid food items)
+            df = df.dropna(subset=['product_name'])
+            
+            # 4. Clean URL columns: if not starts with http/https or contains 'invalid', set to None
+            url_cols = [c for c in df.columns if 'url' in c.lower()]
+            for col in url_cols:
+                df[col] = df[col].astype(str).str.strip()
+                df[col] = df[col].replace(['nan', 'NaN', 'None', 'none', ''], None)
+                # Validate URL structure
+                is_valid_url = df[col].str.startswith(('http://', 'https://'), na=False) & ~df[col].str.contains('invalid', case=False, na=False)
+                df.loc[~is_valid_url, col] = None
+            
             # Ensure all required columns exist in the chunk (fill with None if missing)
             # Ensure all required columns exist in the chunk (fill with None if missing)
             for col in all_required_cols:
             for col in all_required_cols:
                 if col not in df.columns:
                 if col not in df.columns:

+ 111 - 0
local_tools/apply_port_offset.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+import os
+import socket
+import sys
+
+DEFAULT_PORTS = {
+    "BACKEND_PORT": 5000,
+    "MYSQL_PORT": 3306,
+    "AIRFLOW_PORT": 8080,
+    "ZABBIX_PORT": 8081,
+    "JENKINS_PORT": 8088
+}
+
+def is_port_in_use(port):
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.settimeout(1.0)
+        return s.connect_ex(('127.0.0.1', port)) == 0
+
+def load_env(env_path):
+    env_vars = {}
+    if not os.path.exists(env_path):
+        return env_vars
+    with open(env_path, 'r', encoding='utf-8') as f:
+        for line in f:
+            line = line.strip()
+            if line and not line.startswith('#') and '=' in line:
+                key, val = line.split('=', 1)
+                env_vars[key.strip()] = val.strip()
+    return env_vars
+
+def write_env(env_path, updates):
+    if not os.path.exists(env_path):
+        with open(env_path, 'w', encoding='utf-8') as f:
+            for k, v in updates.items():
+                f.write(f"{k}={v}\n")
+        return
+
+    with open(env_path, 'r', encoding='utf-8') as f:
+        lines = f.readlines()
+
+    updated_keys = set()
+    new_lines = []
+    for line in lines:
+        stripped = line.strip()
+        if stripped and not stripped.startswith('#') and '=' in stripped:
+            key, _ = stripped.split('=', 1)
+            key = key.strip()
+            if key in updates:
+                new_lines.append(f"{key}={updates[key]}\n")
+                updated_keys.add(key)
+                continue
+        new_lines.append(line)
+
+    for k, v in updates.items():
+        if k not in updated_keys:
+            new_lines.append(f"{k}={updates[k]}\n")
+
+    with open(env_path, 'w', encoding='utf-8') as f:
+        f.writelines(new_lines)
+
+def main():
+    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    env_path = os.path.join(base_dir, ".env")
+    
+    if not os.path.exists(env_path):
+        print(f"[ERROR] .env file not found at {env_path}")
+        sys.exit(1)
+        
+    env_vars = load_env(env_path)
+    offset_str = env_vars.get("PORT_OFFSET", "0")
+    try:
+        offset = int(offset_str)
+    except ValueError:
+        print(f"[ERROR] PORT_OFFSET in .env is not a valid integer: '{offset_str}'")
+        sys.exit(1)
+        
+    print(f"[INFO] Using PORT_OFFSET={offset} loaded from .env")
+    
+    # Calculate ports and check availability
+    calculated_ports = {}
+    in_use_ports = []
+    
+    for name, default_port in DEFAULT_PORTS.items():
+        target_port = default_port + offset
+        calculated_ports[name] = target_port
+        
+        print(f"Checking target port {name}: {target_port} ... ", end="")
+        sys.stdout.flush()
+        if is_port_in_use(target_port):
+            print("IN USE")
+            in_use_ports.append((name, target_port))
+        else:
+            print("FREE")
+            
+    if in_use_ports:
+        print("\n[ERROR] The following calculated ports are already in use on the host:")
+        for name, port in in_use_ports:
+            print(f"  - {name}: {port}")
+        print("Please resolve the conflict or change PORT_OFFSET in .env before proceeding.")
+        sys.exit(1)
+        
+    # Write updates to .env
+    updates = {name: str(port) for name, port in calculated_ports.items()}
+    write_env(env_path, updates)
+    print("\n[SUCCESS] Successfully verified and updated .env with offsetted ports:")
+    for name, port in calculated_ports.items():
+        print(f"  - {name}: {port}")
+
+if __name__ == "__main__":
+    main()

+ 49 - 0
local_tools/archive_scratch.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+import os
+import shutil
+
+def archive_scratch():
+    # Define directories
+    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    scratch_dir = os.path.join(base_dir, "scratch")
+    user_profile = os.environ.get("USERPROFILE") or os.path.expanduser("~")
+    keep_dir = os.path.join(user_profile, "keep")
+
+    # Create destination folder if not exists
+    if not os.path.exists(keep_dir):
+        os.makedirs(keep_dir)
+        print(f"Created archive directory: {keep_dir}")
+
+    # Check scratch directory contents
+    if not os.path.exists(scratch_dir):
+        print(f"Scratch directory does not exist: {scratch_dir}")
+        return
+
+    # Move files
+    files_moved = 0
+    for filename in os.listdir(scratch_dir):
+        src_path = os.path.join(scratch_dir, filename)
+        
+        # Skip directories if any
+        if not os.path.isfile(src_path):
+            continue
+
+        # Find unique versioned filename
+        version = 1
+        while True:
+            # First file is named test_filter.py;001, then test_filter.py;002...
+            dest_filename = f"{filename};{version:03d}"
+            dest_path = os.path.join(keep_dir, dest_filename)
+            if not os.path.exists(dest_path):
+                break
+            version += 1
+
+        shutil.move(src_path, dest_path)
+        print(f"Moved: {filename} -> {dest_path}")
+        files_moved += 1
+
+    print(f"Scratch archiving completed. Total files archived: {files_moved}")
+
+if __name__ == "__main__":
+    archive_scratch()

+ 245 - 0
local_tools/close_and_export_taiga.py

@@ -0,0 +1,245 @@
+#!/usr/bin/env python
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+import os
+import requests
+import json
+import sys
+from dotenv import load_dotenv
+import urllib3
+
+if hasattr(sys.stdout, 'reconfigure'):
+    sys.stdout.reconfigure(encoding='utf-8')
+if hasattr(sys.stderr, 'reconfigure'):
+    sys.stderr.reconfigure(encoding='utf-8')
+
+# Load local environment configuration
+load_dotenv()
+
+TAIGA_URL = os.getenv("TAIGA_URL")
+if TAIGA_URL:
+    TAIGA_API_URL = f"{TAIGA_URL.rstrip('/')}/api/v1"
+    TAIGA_USERNAME = os.getenv("TAIGA_USER", "FrancoisLange")
+    TAIGA_PASSWORD = os.getenv("TAIGA_PASS")
+    PROJECT_ID = os.getenv("TAIGA_PROJECT_ID", "21")
+else:
+    TAIGA_API_URL = os.getenv("TAIGA_API_URL", "https://api.taiga.io/api/v1")
+    TAIGA_USERNAME = os.getenv("TAIGA_USERNAME")
+    TAIGA_PASSWORD = os.getenv("TAIGA_PASSWORD")
+    PROJECT_ID = os.getenv("TAIGA_PROJECT_ID")
+
+urllib3.disable_warnings()
+session = requests.Session()
+session.verify = False
+
+def authenticate_taiga():
+    print("[INFO] Authenticating with Taiga...")
+    payload = {
+        "type": "normal",
+        "username": TAIGA_USERNAME,
+        "password": TAIGA_PASSWORD
+    }
+    response = session.post(f"{TAIGA_API_URL}/auth", json=payload)
+    response.raise_for_status()
+    return response.json().get("auth_token")
+
+def get_headers(token):
+    return {
+        "Authorization": f"Bearer {token}",
+        "Content-Type": "application/json"
+    }
+
+def main():
+    if not TAIGA_USERNAME or not TAIGA_PASSWORD or not PROJECT_ID:
+        print("[ERROR] Missing Taiga credentials in .env")
+        sys.exit(1)
+
+    token = authenticate_taiga()
+    headers = get_headers(token)
+
+    # 1. Retrieve all User Stories to locate the target one
+    print("[INFO] Fetching user stories...")
+    r = session.get(f"{TAIGA_API_URL}/userstories?project={PROJECT_ID}", headers=headers)
+    r.raise_for_status()
+    user_stories = r.json()
+    
+    target_us_id = None
+    target_subject = "As a team, we need enhanced communication and documentation."
+    for us in user_stories:
+        if us["subject"] == target_subject:
+            target_us_id = us["id"]
+            break
+            
+    if not target_us_id and user_stories:
+        # Fallback to the first story if the target one isn't found
+        target_us_id = user_stories[0]["id"]
+        print(f"[WARNING] Target story not found. Using fallback story ID: {target_us_id}")
+    elif not target_us_id:
+        print("[ERROR] No user stories found in the project.")
+        sys.exit(1)
+    else:
+        print(f"[INFO] Found target user story ID: {target_us_id}")
+
+    # 2. Create the documentation task if it doesn't exist yet
+    task_subject = "Create skill directory setup and configuration guide"
+    
+    # Check if task already exists
+    print("[INFO] Checking if documentation task already exists...")
+    r = session.get(f"{TAIGA_API_URL}/tasks?project={PROJECT_ID}", headers=headers)
+    r.raise_for_status()
+    tasks = r.json()
+    
+    task_exists = False
+    for t in tasks:
+        if t["subject"] == task_subject:
+            task_exists = True
+            break
+            
+    if not task_exists:
+        print(f"[INFO] Creating task: '{task_subject}'...")
+        task_payload = {
+            "subject": task_subject,
+            "project": int(PROJECT_ID),
+            "user_story": target_us_id
+        }
+        r = session.post(f"{TAIGA_API_URL}/tasks", json=task_payload, headers=headers)
+        r.raise_for_status()
+        print("[SUCCESS] Task created successfully!")
+        
+        # Re-fetch tasks to include the new one
+        r = session.get(f"{TAIGA_API_URL}/tasks?project={PROJECT_ID}", headers=headers)
+        r.raise_for_status()
+        tasks = r.json()
+    else:
+        print("[INFO] Documentation task already exists.")
+
+    # 3. Fetch and close all tasks
+    print(f"[INFO] Closing {len(tasks)} tasks...")
+    for t in tasks:
+        tid = t["id"]
+        # Skip if already closed
+        if t.get("status_extra_info", {}).get("is_closed", False) or t.get("is_closed", False):
+            print(f"  - Task already closed: {t['subject']}")
+            continue
+        print(f"  - Closing task: {t['subject']} (ID: {tid}, Version: {t.get('version')})")
+        # Try to find the closed status ID dynamically or use a standard close logic
+        # In self-hosted Taiga, closed status might have a different ID, so let's check
+        # status and set to is_closed.
+        closed_status_id = None
+        # We can fetch statuses to find one that is_closed
+        try:
+            statuses_resp = session.get(f"{TAIGA_API_URL}/task-statuses?project={PROJECT_ID}", headers=headers).json()
+            closed_status_id = next((s["id"] for s in statuses_resp if s["is_closed"]), None)
+        except Exception:
+            pass
+        if not closed_status_id:
+            closed_status_id = 8901961  # Fallback
+            
+        r = session.patch(f"{TAIGA_API_URL}/tasks/{tid}", json={"status": closed_status_id, "version": t["version"]}, headers=headers)
+        if not r.ok:
+            print(f"[ERROR] Failed to close task {tid}: {r.text}")
+        r.raise_for_status()
+
+    # 4. Close all User Stories
+    r = session.get(f"{TAIGA_API_URL}/userstories?project={PROJECT_ID}", headers=headers)
+    r.raise_for_status()
+    user_stories = r.json()
+    
+    print(f"[INFO] Closing {len(user_stories)} user stories...")
+    for us in user_stories:
+        usid = us["id"]
+        if us.get("status_extra_info", {}).get("is_closed", False) or us.get("is_closed", False):
+            print(f"  - Story already closed: {us['subject']}")
+            continue
+        print(f"  - Closing story: {us['subject']} (ID: {usid}, Version: {us.get('version')})")
+        closed_us_status_id = None
+        try:
+            statuses_resp = session.get(f"{TAIGA_API_URL}/userstory-statuses?project={PROJECT_ID}", headers=headers).json()
+            closed_us_status_id = next((s["id"] for s in statuses_resp if s["is_closed"]), None)
+        except Exception:
+            pass
+        if not closed_us_status_id:
+            closed_us_status_id = 10832456  # Fallback
+            
+        r = session.patch(f"{TAIGA_API_URL}/userstories/{usid}", json={"status": closed_us_status_id, "version": us["version"]}, headers=headers)
+        if not r.ok:
+            print(f"[ERROR] Failed to close story {usid}: {r.text}")
+        r.raise_for_status()
+
+    # 5. Close all Sprints/Milestones
+    print("[INFO] Fetching Sprints...")
+    r = session.get(f"{TAIGA_API_URL}/milestones?project={PROJECT_ID}", headers=headers)
+    r.raise_for_status()
+    milestones = r.json()
+
+    print(f"[INFO] Closing {len(milestones)} milestones...")
+    for m in milestones:
+        mid = m["id"]
+        if m["closed"]:
+            print(f"  - Milestone already closed: {m['name']}")
+            continue
+        print(f"  - Closing milestone: {m['name']} (ID: {mid}, Version: {m.get('version')})")
+        r = session.patch(f"{TAIGA_API_URL}/milestones/{mid}", json={"closed": True, "version": m.get("version")}, headers=headers)
+        if not r.ok:
+            print(f"[ERROR] Failed to close milestone {mid}: {r.text}")
+        r.raise_for_status()
+
+    # 6. Fetch final state and export to JSON
+    print("[INFO] Fetching final project state for export...")
+    
+    r = session.get(f"{TAIGA_API_URL}/milestones?project={PROJECT_ID}", headers=headers)
+    final_milestones = r.json()
+    
+    r = session.get(f"{TAIGA_API_URL}/userstories?project={PROJECT_ID}", headers=headers)
+    final_stories = r.json()
+    
+    r = session.get(f"{TAIGA_API_URL}/tasks?project={PROJECT_ID}", headers=headers)
+    final_tasks = r.json()
+
+    export_data = {
+        "project_id": PROJECT_ID,
+        "sprints": [
+            {
+                "id": m["id"],
+                "name": m["name"],
+                "estimated_start": m["estimated_start"],
+                "estimated_finish": m["estimated_finish"],
+                "closed": m["closed"]
+            } for m in final_milestones
+        ],
+        "user_stories": [
+            {
+                "id": us["id"],
+                "subject": us["subject"],
+                "status_id": us["status"],
+                "is_closed": us.get("is_closed", False),
+                "milestone": us["milestone"]
+            } for us in final_stories
+        ],
+        "tasks": [
+            {
+                "id": t["id"],
+                "subject": t["subject"],
+                "status_id": t["status"],
+                "is_closed": t.get("is_closed", False),
+                "user_story": t["user_story"]
+            } for t in final_tasks
+        ]
+    }
+
+    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    export_path = os.path.join(base_dir, "docs", "taiga_export.json")
+    
+    with open(export_path, "w", encoding="utf-8") as f:
+        json.dump(export_data, f, indent=4, ensure_ascii=False)
+    print(f"[SUCCESS] Saved export to: {export_path}")
+
+    # Also save to the special taiga directory dump path if it exists
+    taiga_dir = os.path.join(base_dir, "taiga")
+    if os.path.exists(taiga_dir):
+        special_path = os.path.join(taiga_dir, "local-food-ai-1-eab691c0-9c19-4dce-ac66-3b8fade77ef7.json")
+        with open(special_path, "w", encoding="utf-8") as f:
+            json.dump(export_data, f, indent=4, ensure_ascii=False)
+        print(f"[SUCCESS] Saved export to: {special_path}")
+
+if __name__ == "__main__":
+    main()

+ 22 - 0
local_tools/commit-msg

@@ -0,0 +1,22 @@
+#!/bin/sh
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+
+commit_msg_file="$1"
+commit_msg=$(cat "$commit_msg_file")
+
+# Skip checking merge commits or empty commits
+if echo "$commit_msg" | grep -qE "^Merge "; then
+    exit 0
+fi
+
+# Regex pattern to check if the commit message starts with TG-<num>, US#<num>, or [#<num>]
+if echo "$commit_msg" | grep -qE "^(TG-[0-9]+|US#[0-9]+|\[#[0-9]+\])"; then
+    exit 0
+else
+    echo "❌ Error: Commit message must start with a Taiga task/story tag (e.g., TG-123, US#123, or [#123])."
+    echo "Your commit message was:"
+    echo "--------------------------------------------------"
+    echo "$commit_msg"
+    echo "--------------------------------------------------"
+    exit 1
+fi

+ 87 - 0
local_tools/git-ident-filter.py

@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+import sys
+import os
+import subprocess
+import re
+from datetime import datetime
+
+# Force LF-only line endings on Windows for stdin and stdout to prevent automatic CRLF translation
+if hasattr(sys.stdin, 'reconfigure'):
+    sys.stdin.reconfigure(newline='\n')
+if hasattr(sys.stdout, 'reconfigure'):
+    sys.stdout.reconfigure(newline='\n')
+
+# Detect execution mode (clean or smudge) passed by Git
+mode = sys.argv[1] if len(sys.argv) > 1 else "smudge"
+
+def get_git_info(file_path):
+    """Retrieves commit metadata for the specific file using git log, or falls back to system context."""
+    try:
+        # 1. Query git log for the last commit details of the specific file
+        cmd = [
+            "git", "log", "-1",
+            "--date=format:%Y/%m/%d %H:%M:%S",
+            "--format=%an|%ae|%ad|%cn|%ce|%cd|%H|%D|%N",
+            "--", file_path
+        ]
+        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip()
+        if out:
+            parts = out.split('|')
+            if len(parts) == 9:
+                return parts
+    except Exception:
+        pass
+    
+    # 2. Fallback: Query local Git configuration if file is not committed yet
+    try:
+        author_name = subprocess.check_output(["git", "config", "user.name"], stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip() or "system"
+        author_email = subprocess.check_output(["git", "config", "user.email"], stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip() or "system@mail.com"
+    except Exception:
+        author_name = "system"
+        author_email = "system@mail.com"
+        
+    now_str = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
+    return [author_name, author_email, now_str, author_name, author_email, now_str, "Not Committed Yet", "local", "none"]
+
+if mode == "clean":
+    # CLEAN Mode: Replaces any smudged $Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$ tag back to the standard neutral git representation
+    content = sys.stdin.read()
+    # Non-greedy substitution to restore standard placeholder format for Git storage
+    cleaned = re.sub(
+        r'\$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$)?[^$]*?\$', 
+        r'$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$', 
+        content
+    )
+    sys.stdout.write(cleaned)
+
+else:
+    # SMUDGE Mode: Dynamically injects actual project, file path, and commit metadata into the file
+    try:
+        # Get absolute path of repository to find project directory name
+        toplevel = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL).decode().strip()
+        project_name = os.path.basename(toplevel)
+
+        # Get the relative path of the file being smudged
+        file_name = sys.argv[2] if len(sys.argv) > 2 else "unknown_file"
+
+        # Read the file content sent by Git on stdin
+        content = sys.stdin.read()
+
+        # Query git log metadata or local fallbacks
+        info = get_git_info(file_name)
+
+        # Format replacement string using LocalFoodAI and app.py
+        replacement = f"$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+
+        # Regex replacement targeting the dynamic format placeholders
+        smudged = re.sub(
+            r'\$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$', 
+            replacement, 
+            content
+        )
+        sys.stdout.write(smudged)
+
+    except Exception:
+        # Security fallback: If executed outside Git repo, write stream unchanged
+        sys.stdout.write(sys.stdin.read())

+ 10 - 0
local_tools/setup_filters.bat

@@ -0,0 +1,10 @@
+::ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+@echo off
+:: Configuration des filtres avec des chemins relatifs portables (Git s'execute toujours a la racine du depot)
+@git config filter.ident-dynamic.clean "python local_tools/git-ident-filter.py clean"
+@git config filter.ident-dynamic.smudge "python local_tools/git-ident-filter.py smudge %%f"
+:: Configuration du format de date universel
+@git config log.date "format:%%Y/%%m/%%d %%H:%%M:%%S"
+:: Installation du hook commit-msg
+@copy /Y local_tools\commit-msg .git\hooks\commit-msg >nul
+@echo ✅ Filtres Git et commit-msg hook configures avec succes pour Windows Natif.

+ 8 - 0
local_tools/setup_filters.sh

@@ -0,0 +1,8 @@
+#!/bin/sh
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+git config filter.ident-dynamic.clean "python3 local_tools/git-ident-filter.py clean"
+git config filter.ident-dynamic.smudge "python3 local_tools/git-ident-filter.py smudge %f"
+git config log.date "format:%Y/%m/%d %H:%M:%S"
+cp local_tools/commit-msg .git/hooks/commit-msg
+chmod +x .git/hooks/commit-msg
+echo "✅ Filtres Git et commit-msg hook configurés avec succès pour Unix / WSL."

+ 5 - 2
scripts/deploy_to_server.py

@@ -18,10 +18,13 @@ def deploy():
     user = os.environ.get('SERVER_USER')
     user = os.environ.get('SERVER_USER')
     password = os.environ.get('SERVER_PASS')
     password = os.environ.get('SERVER_PASS')
 
 
-    if not all([host, user, password]):
+    if not all([host, user]):
         print("Error: Server credentials not found in .env file.")
         print("Error: Server credentials not found in .env file.")
         return
         return
 
 
+    if password == "your_db_password_here" or password == "your_password_here" or not password:
+        password = None
+
     print(f"Connecting to {user}@{host}...")
     print(f"Connecting to {user}@{host}...")
     
     
     ssh = paramiko.SSHClient()
     ssh = paramiko.SSHClient()
@@ -31,7 +34,7 @@ def deploy():
         ssh.connect(host, username=user, password=password, timeout=10)
         ssh.connect(host, username=user, password=password, timeout=10)
         print("Connected successfully!")
         print("Connected successfully!")
         
         
-        command = "cd food_project && rm -f git_version.txt git_id.txt && git pull && git log -1 --date='format:%Y/%m/%d %H:%M:%S' --format='%cd' > git_version.txt && git log -1 --date='format:%Y/%m/%d %H:%M:%S' --format='%cd %h' app.py > git_id.txt && docker-compose up -d --build"
+        command = "cd food_project && rm -f git_version.txt git_id.txt && git pull && docker-compose up -d --build"
         print(f"Executing: {command}")
         print(f"Executing: {command}")
         
         
         stdin, stdout, stderr = ssh.exec_command(command)
         stdin, stdout, stderr = ssh.exec_command(command)

+ 7 - 1
scripts/taiga_sync_final.py

@@ -1,9 +1,15 @@
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 import requests
 import requests
-#ident "@(#)$Format:LocalFoodAI:taiga_sync_final.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 import urllib3
 import urllib3
 import os
 import os
 import re
 import re
+import sys
+
+if hasattr(sys.stdout, 'reconfigure'):
+    sys.stdout.reconfigure(encoding='utf-8')
+if hasattr(sys.stderr, 'reconfigure'):
+    sys.stderr.reconfigure(encoding='utf-8')
 
 
 urllib3.disable_warnings()
 urllib3.disable_warnings()
 
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 38
taiga/local-food-ai-1-eab691c0-9c19-4dce-ac66-3b8fade77ef7.json


+ 3 - 2
unit_converter.py

@@ -1,6 +1,6 @@
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 import re
 import re
-#ident "@(#)$Format:LocalFoodAI:unit_converter.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
+#ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
 
 
 class UnitConverter:
 class UnitConverter:
     """
     """
@@ -28,7 +28,6 @@ class UnitConverter:
         'pinch': 0.36, # rough estimate
         'pinch': 0.36, # rough estimate
         'dash': 0.72,
         'dash': 0.72,
         'xl': 64.0,
         'xl': 64.0,
-        'l': 50.0,
         'm': 44.0,
         'm': 44.0,
         's': 38.0,
         's': 38.0,
         'extra': 64.0,
         'extra': 64.0,
@@ -91,6 +90,8 @@ class UnitConverter:
     # Direct weight conversions (already in weight, just need unit conversion)
     # Direct weight conversions (already in weight, just need unit conversion)
     WEIGHT_UNITS_G = {
     WEIGHT_UNITS_G = {
         'g': 1.0,
         'g': 1.0,
+        'gr': 1.0,
+        'grs': 1.0,
         'gram': 1.0,
         'gram': 1.0,
         'kg': 1000.0,
         'kg': 1000.0,
         'kilo': 1000.0,
         'kilo': 1000.0,

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff