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$"
-*.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$
 # $Author$
 # $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$"
 import streamlit as st
 import extra_streamlit_components as stx
@@ -361,6 +361,16 @@ def reset_password(username: str, email: str) -> Any:
     return False
 
 # 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):
     from datetime import datetime
     try:
@@ -376,35 +386,53 @@ def reformat_git_date(date_str):
 
 def render_version():
     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:
-        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:
-        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")
 
@@ -672,12 +700,16 @@ with tab_explore:
                     l_str = "" if limit_rc == "All" else f"LIMIT {limit_rc}"
                     query = f"""
                         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,
                                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`,
                                min.calcium_100g, min.iron_100g, min.magnesium_100g, min.potassium_100g, min.zinc_100g
                         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
                             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'
@@ -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))
                     results = cursor.fetchall()
                     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:
                         # Fetch EAV Medical Profile
@@ -712,7 +744,7 @@ with tab_explore:
                         
                         st.markdown("### 🛠️ Dynamic Column Display")
                         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',
                             '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")
                             
                         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
                         for col in df_display.columns:
                             if df_display[col].dtype == 'object':
@@ -820,18 +858,26 @@ with tab_explore:
                         df_display.index = range(1, len(df_display) + 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.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"):
                             with st.spinner("AI is dynamically evaluating these records against your profile..."):
                                 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"
+                                start_eval = time.time()
                                 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:
                                     response = ollama.chat(model=ACTIVE_MODEL, messages=[{'role': 'user', 'content': eval_prompt}], stream=True)
                                     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:
                                     error_msg = str(e).lower()
                                     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)
                     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"])
+                    raw_ingredient_filter = col_scope.radio("Raw Ingredient Only?", ["No", "Yes"], horizontal=True)
                     
                     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 ""
                         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"""
-                            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 (
-                                SELECT code, product_name
+                                SELECT code, product_name, image_small_url, image_ingredients_small_url, image_nutrition_small_url
                                 FROM food_db.products_core
                                 WHERE MATCH({m_col}) AGAINST(%s IN BOOLEAN MODE)
                                 AND product_name IS NOT NULL AND product_name != '' AND product_name != 'None'
+                                {raw_clause}
                                 ORDER BY LENGTH(product_name) ASC
                             ) c
                             JOIN food_db.products_macros m ON c.code = m.code
@@ -1047,11 +1099,36 @@ with tab_plate:
                         search_res = execute_search("ingredients_text")
                         
                     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
 
                 if st.session_state.get('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}
                     selected_str = st.selectbox("Select Product", list(options.keys()))
                     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")
                     
                     if st.button("Add Item to Plate"):
+                        start_add = time.time()
                         # Use UnitConverter to parse
                         grams = UnitConverter.parse_and_convert(add_amount_str, product_name=selected_product['product_name'])
                         if grams is not None:
@@ -1066,6 +1144,8 @@ with tab_plate:
                                           (active_p_id, selected_product['code'], grams))
                             conn.commit()
                             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.rerun()
                         else:
@@ -1110,6 +1190,11 @@ with tab_planner:
             Dietary constraint: {diet_pref}. Additional notes: {extra_notes}.
             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:
             - 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:
@@ -1145,7 +1230,7 @@ with tab_planner:
                 clean_stream = filter_scratchpad_stream(response, raw_chunks)
                 ai_reply = st.write_stream(clean_stream)
                 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
                 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
 
 ## 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:
 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.
-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
 
 ## 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.
 
 ## 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)
 
 ## 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.
 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.
-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)
 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
 - Execute the `data_sync.sh` cron job monthly.
 - 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
 
 ## Requirements
@@ -17,4 +15,4 @@ The current version is #ident "@(#)$Format:LocalFoodAI:app.py:%an:%ae:%ad:%cn:%c
 4. **Deploy Stack**:
    - For regular production: `docker compose up -d --build`
    - 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
 
 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-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-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
-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
-- **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 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 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 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
 
 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]
-> **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
 
 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
 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
 - 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
 
 ## 1. System Vision
@@ -40,4 +38,4 @@ An automated clinical diet planner.
 ## 3. Supported Health & Medical Profiles
 - **Conditions**: Pregnant, Breastfeeding, Low Fat, Osteoporosis.
 - **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
 
 ## 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.
 
 ## 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
 To deploy on Windows Subsystem for Linux:
 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
-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
     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_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_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
     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
             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)
             for col in all_required_cols:
                 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')
     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.")
         return
 
+    if password == "your_db_password_here" or password == "your_password_here" or not password:
+        password = None
+
     print(f"Connecting to {user}@{host}...")
     
     ssh = paramiko.SSHClient()
@@ -31,7 +34,7 @@ def deploy():
         ssh.connect(host, username=user, password=password, timeout=10)
         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}")
         
         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$"
 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 os
 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()
 

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$"
 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:
     """
@@ -28,7 +28,6 @@ class UnitConverter:
         'pinch': 0.36, # rough estimate
         'dash': 0.72,
         'xl': 64.0,
-        'l': 50.0,
         'm': 44.0,
         's': 38.0,
         'extra': 64.0,
@@ -91,6 +90,8 @@ class UnitConverter:
     # Direct weight conversions (already in weight, just need unit conversion)
     WEIGHT_UNITS_G = {
         'g': 1.0,
+        'gr': 1.0,
+        'grs': 1.0,
         'gram': 1.0,
         'kg': 1000.0,
         'kilo': 1000.0,

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