ingest_csv.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. #!/usr/bin/env python3
  2. #ident "@(#)$Format:LocalFoodAI_lanfr144:ingest_csv.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  3. import pandas as pd
  4. import myloginpath
  5. import urllib.parse
  6. from sqlalchemy import create_engine, text
  7. from sqlalchemy.types import VARCHAR, DOUBLE
  8. from sqlalchemy.dialects.mysql import LONGTEXT
  9. import os
  10. import sys
  11. from snmp_notifier import notifier
  12. def get_loader_engine():
  13. try:
  14. import os
  15. db_host = os.environ.get('DB_HOST')
  16. db_user = os.environ.get('DB_USER')
  17. db_pass = os.environ.get('DB_PASS')
  18. if db_host and db_user and db_pass:
  19. password = urllib.parse.quote_plus(db_pass)
  20. conn_str = f"mysql+pymysql://{db_user}:{password}@{db_host}/food_db?charset=utf8mb4"
  21. return create_engine(conn_str)
  22. conf = myloginpath.parse('app_loader')
  23. user = conf.get('user')
  24. password = urllib.parse.quote_plus(conf.get('password'))
  25. host = conf.get('host', '127.0.0.1')
  26. database = 'food_db'
  27. conn_str = f"mysql+pymysql://{user}:{password}@{host}/{database}?charset=utf8mb4"
  28. return create_engine(conn_str)
  29. except Exception as e:
  30. print(f"❌ Failed to parse myloginpath or create engine: {e}")
  31. sys.exit(1)
  32. def init_ingestion_status_table(engine):
  33. with engine.begin() as conn:
  34. conn.execute(text("""
  35. CREATE TABLE IF NOT EXISTS ingestion_status (
  36. id INT AUTO_INCREMENT PRIMARY KEY,
  37. filename VARCHAR(255),
  38. start_time DATETIME,
  39. end_time DATETIME,
  40. rows_loaded INT,
  41. status VARCHAR(50),
  42. error_message TEXT
  43. )
  44. """))
  45. def ingest_file(filename, engine):
  46. if not os.path.exists(filename):
  47. print(f"File {filename} not found locally.")
  48. return False
  49. print(f"\n🚀 Found {filename}! Starting grouped vertical partition ingestion...")
  50. # Initialize ingestion status record
  51. init_ingestion_status_table(engine)
  52. ingest_id = None
  53. try:
  54. with engine.begin() as conn:
  55. conn.execute(text("""
  56. INSERT INTO ingestion_status (filename, start_time, rows_loaded, status)
  57. VALUES (:filename, NOW(), 0, 'RUNNING')
  58. """), {"filename": filename})
  59. res_id = conn.execute(text("SELECT LAST_INSERT_ID()"))
  60. ingest_id = res_id.scalar()
  61. except Exception as e:
  62. print(f"Warning: Could not create ingestion status record: {e}")
  63. chunk_size = 10000
  64. total_processed = 0
  65. max_chunks = int(os.environ.get('MAX_CHUNKS', '0'))
  66. chunks_count = 0
  67. # Define the groupings
  68. groups = {
  69. 'products_core': [
  70. 'code', 'product_name', 'generic_name', 'brands', 'ingredients_text',
  71. 'url', 'image_url', 'image_small_url', 'image_ingredients_url',
  72. 'image_ingredients_small_url', 'image_nutrition_url', 'image_nutrition_small_url'
  73. ],
  74. 'products_allergens': ['code', 'allergens'],
  75. 'products_macros': ['code', 'energy-kcal_100g', 'proteins_100g', 'fat_100g', 'carbohydrates_100g', 'sugars_100g', 'fiber_100g', 'sodium_100g', 'salt_100g', 'cholesterol_100g'],
  76. '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'],
  77. 'products_minerals': ['code', 'calcium_100g', 'iron_100g', 'magnesium_100g', 'potassium_100g', 'zinc_100g']
  78. }
  79. # Schema Auto-Migration: check if columns mismatch, and drop table for clean recreation
  80. try:
  81. with engine.connect() as conn:
  82. for table_name, columns in groups.items():
  83. try:
  84. res = conn.execute(text(f"DESCRIBE {table_name}"))
  85. existing_cols = [row[0] for row in res.fetchall()]
  86. mismatch = False
  87. for col in columns:
  88. if col not in existing_cols:
  89. mismatch = True
  90. break
  91. if mismatch:
  92. print(f"⚠️ Columns mismatch for table {table_name}. Dropping table for recreation.")
  93. conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
  94. conn.commit()
  95. except Exception:
  96. pass
  97. except Exception as e:
  98. print(f"Warning: Could not connect to database for schema check: {e}")
  99. # Pre-calculate what to read
  100. all_required_cols = list(set([col for cols in groups.values() for col in cols]))
  101. for chunk in pd.read_csv(filename, sep='\t', dtype=str, chunksize=chunk_size, on_bad_lines='skip', low_memory=False, encoding='utf-8'):
  102. chunks_count += 1
  103. if max_chunks > 0 and chunks_count > max_chunks:
  104. print(f"\nReached MAX_CHUNKS limit ({max_chunks}). Ingestion stopped early.")
  105. break
  106. try:
  107. # Drop rows with missing codes
  108. if 'code' not in chunk.columns:
  109. continue
  110. df = chunk.dropna(subset=['code']).drop_duplicates(subset=['code']).copy()
  111. # Clean and consolidate CSV data
  112. # 1. Clean code (must be numeric digits/characters, strip whitespace)
  113. df['code'] = df['code'].astype(str).str.strip()
  114. df = df[df['code'] != '']
  115. # 2. Clean product_name: strip whitespace, fill empty with generic name or brand, or skip if completely empty
  116. if 'product_name' in df.columns:
  117. df['product_name'] = df['product_name'].astype(str).str.strip()
  118. if 'generic_name' in df.columns:
  119. df['product_name'] = df['product_name'].fillna(df['generic_name'].astype(str).str.strip())
  120. if 'brands' in df.columns:
  121. df['product_name'] = df['product_name'].fillna(df['brands'].astype(str).str.strip())
  122. # Replace string representations of "nan" / "none"
  123. df['product_name'] = df['product_name'].replace(['nan', 'NaN', 'None', 'none', ''], None)
  124. # 3. Drop rows with missing or null product_name (consolidate only valid food items)
  125. df = df.dropna(subset=['product_name'])
  126. # 4. Clean URL columns: if not starts with http/https or contains 'invalid', set to None
  127. url_cols = [c for c in df.columns if 'url' in c.lower()]
  128. for col in url_cols:
  129. df[col] = df[col].astype(str).str.strip()
  130. df[col] = df[col].replace(['nan', 'NaN', 'None', 'none', ''], None)
  131. # Validate URL structure
  132. is_valid_url = df[col].str.startswith(('http://', 'https://'), na=False) & ~df[col].str.contains('invalid', case=False, na=False)
  133. df.loc[~is_valid_url, col] = None
  134. # Ensure all required columns exist in the chunk (fill with None if missing)
  135. for col in all_required_cols:
  136. if col not in df.columns:
  137. df[col] = None
  138. for table_name, columns in groups.items():
  139. slice_df = df[columns].copy()
  140. # Cast datatypes: core and allergens are TEXT, others are DOUBLE
  141. if table_name in ['products_core', 'products_allergens']:
  142. sql_dtypes = {col: LONGTEXT() for col in columns if col != 'code'}
  143. sql_dtypes['code'] = VARCHAR(255)
  144. else:
  145. # Convert to numeric (double) safely
  146. for col in columns:
  147. if col != 'code':
  148. slice_df[col] = pd.to_numeric(slice_df[col], errors='coerce')
  149. sql_dtypes = {col: DOUBLE() for col in columns if col != 'code'}
  150. sql_dtypes['code'] = VARCHAR(255)
  151. # Write to temp table
  152. temp_name = f"temp_{table_name}"
  153. slice_df.to_sql(temp_name, con=engine, if_exists='replace', index=False, dtype=sql_dtypes)
  154. # UPSERT into final table with Primary Key enforcement
  155. with engine.begin() as conn:
  156. # Ensure temp table has a primary key on code so LIKE copies it, or alter it later
  157. conn.execute(text(f"ALTER TABLE {temp_name} ADD PRIMARY KEY (code);"))
  158. conn.execute(text(f"CREATE TABLE IF NOT EXISTS {table_name} LIKE {temp_name}"))
  159. cols_str = ", ".join([f"`{c}`" for c in columns])
  160. # Generate ON DUPLICATE KEY UPDATE clause with COALESCE to fill nulls
  161. update_cols = ", ".join([f"`{c}` = COALESCE(`{table_name}`.`{c}`, VALUES(`{c}`))" for c in columns if c != 'code'])
  162. if update_cols:
  163. upsert_query = f"INSERT INTO {table_name} ({cols_str}) SELECT {cols_str} FROM {temp_name} ON DUPLICATE KEY UPDATE {update_cols}"
  164. else:
  165. upsert_query = f"INSERT IGNORE INTO {table_name} ({cols_str}) SELECT {cols_str} FROM {temp_name}"
  166. conn.execute(text(upsert_query))
  167. conn.execute(text(f"DROP TABLE IF EXISTS {temp_name}"))
  168. total_processed += len(df)
  169. print(f" Successfully appended {total_processed} rows into grouped tables...", end="\r")
  170. # Update rows loaded in database
  171. if ingest_id is not None:
  172. try:
  173. with engine.begin() as conn:
  174. conn.execute(text("""
  175. UPDATE ingestion_status
  176. SET rows_loaded = :rows
  177. WHERE id = :id
  178. """), {"rows": total_processed, "id": ingest_id})
  179. except Exception as e:
  180. print(f"Warning: Could not update ingestion progress: {e}")
  181. if total_processed % 50000 == 0:
  182. notifier.send_alert(f"Ingestion Milestone: {total_processed} rows processed")
  183. except BaseException as e:
  184. notifier.send_alert(f"Ingestion Exception: {str(e)}")
  185. print(f"\n [Warning] Chunk skipped due to error: {e}")
  186. notifier.send_alert(f"Ingestion Finished: {filename}")
  187. print(f"\n✅ Finished importing {filename}.")
  188. if ingest_id is not None:
  189. try:
  190. with engine.begin() as conn:
  191. conn.execute(text("""
  192. UPDATE ingestion_status
  193. SET end_time = NOW(), status = 'COMPLETED'
  194. WHERE id = :id
  195. """), {"id": ingest_id})
  196. except Exception as e:
  197. print(f"Warning: Could not update ingestion status: {e}")
  198. # Auto-create required FULLTEXT search indexes if not present
  199. print("🚀 Creating FULLTEXT indexes on products_core for Streamlit search RAG...")
  200. for idx_name, cols in [
  201. ('idx_prod_name', 'product_name'),
  202. ('idx_ing_text', 'ingredients_text'),
  203. ('idx_prod_ing', 'product_name, ingredients_text')
  204. ]:
  205. try:
  206. with engine.connect() as conn:
  207. res = conn.execute(text(f"SHOW INDEX FROM products_core WHERE Key_name = '{idx_name}'"))
  208. exists = len(res.fetchall()) > 0
  209. if not exists:
  210. print(f"Index {idx_name} not found. Creating index...")
  211. with engine.begin() as conn:
  212. conn.execute(text(f"ALTER TABLE products_core ADD FULLTEXT INDEX {idx_name} ({cols})"))
  213. print(f"Created FULLTEXT index: {idx_name}")
  214. else:
  215. print(f"Index {idx_name} already exists.")
  216. except Exception as e:
  217. print(f"Warning: Could not create index {idx_name}: {e}")
  218. return True
  219. if __name__ == "__main__":
  220. print("Initiating OpenFoodFacts Grouped Vertical Ingestion Process...")
  221. engine = get_loader_engine()
  222. if "--wipe" in sys.argv or "-w" in sys.argv:
  223. print("Wiping existing products tables and views...")
  224. try:
  225. with engine.begin() as conn:
  226. conn.execute(text("DROP VIEW IF EXISTS products"))
  227. conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
  228. conn.execute(text("DROP TABLE IF EXISTS products_core"))
  229. conn.execute(text("DROP TABLE IF EXISTS products_allergens"))
  230. conn.execute(text("DROP TABLE IF EXISTS products_macros"))
  231. conn.execute(text("DROP TABLE IF EXISTS products_vitamins"))
  232. conn.execute(text("DROP TABLE IF EXISTS products_minerals"))
  233. conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
  234. print("Database wipe complete.")
  235. except Exception as e:
  236. print(f"Warning: Could not wipe tables: {e}")
  237. processed_en = ingest_file('data/en.openfoodfacts.org.products.csv', engine)
  238. processed_fr = ingest_file('data/fr.openfoodfacts.org.products.csv', engine)
  239. if not processed_en and not processed_fr:
  240. print("\n❌ Could not find CSVs.")
  241. else:
  242. print("\n🎉 Full database reload complete! Ready for AI RAG.")