from flask import Flask, request, jsonify from flask_cors import CORS from google import genai from google.genai import types import os import json import re import random from dotenv import load_dotenv import requests import time import traceback load_dotenv() app = Flask(__name__) CORS(app) # --- CONFIGURATION --- api_key = os.getenv("GEMINI_API_KEY") client = genai.Client(api_key=api_key) if api_key else None FS_CLIENT_ID = os.getenv("FATSECRET_CLIENT_ID") FS_CLIENT_SECRET = os.getenv("FATSECRET_CLIENT_SECRET") FS_TOKEN = None FS_TOKEN_EXPIRY = 0 # --- HELPER FUNCTIONS --- def clean_json_text(text): text = text.strip() if text.startswith("```"): parts = text.split("\n", 1) if len(parts) > 1: text = parts[1] if text.endswith("```"): text = text.rsplit("\n", 1)[0] return text.strip() def mock_analyze_food(query): return { "food_name": query.title(), "calories": random.randint(150, 600), "protein": random.randint(5, 30), "carbs": random.randint(20, 80), "fat": random.randint(5, 25), "portion": "1 serving (Mock)", "health_tip": "Mock Data." } def get_fatsecret_token(): global FS_TOKEN, FS_TOKEN_EXPIRY if FS_TOKEN and time.time() < FS_TOKEN_EXPIRY: return FS_TOKEN auth_url = "https://oauth.fatsecret.com/connect/token" try: response = requests.post( auth_url, data={"grant_type": "client_credentials", "scope": "basic"}, auth=(FS_CLIENT_ID, FS_CLIENT_SECRET), timeout=10 ) response.raise_for_status() data = response.json() FS_TOKEN = data['access_token'] FS_TOKEN_EXPIRY = time.time() + data['expires_in'] - 60 return FS_TOKEN except Exception as e: return None def search_fatsecret(query): token = get_fatsecret_token() if not token: return None search_url = "https://platform.fatsecret.com/rest/server.api" headers = {"Authorization": f"Bearer {token}"} params = { "method": "foods.search", "search_expression": query, "format": "json", "max_results": 1 } try: response = requests.get(search_url, headers=headers, params=params, timeout=10) if response.status_code != 200: return None data = response.json() if "foods" in data and "food" in data["foods"]: food_entry = data["foods"]["food"] if isinstance(food_entry, list): food_entry = food_entry[0] food_desc = food_entry['food_description'] def extract_val(text, key, unit=""): import re try: match = re.search(rf"{key}:\s*(\d+\.?\d*)\s*{unit}", text, re.IGNORECASE) if match: return float(match.group(1)) except: pass return 0 return { "food_name": food_entry['food_name'], "calories": round(extract_val(food_desc, "Calories", "kcal"), 1), "protein": round(extract_val(food_desc, "Protein", "g"), 1), "carbs": round(extract_val(food_desc, "Carbs", "g"), 1), "fat": round(extract_val(food_desc, "Fat", "g"), 1), "health_tip": "Data verified from FatSecret Database." } except Exception as e: print(f"FatSecret Error: {e}") return None return None def format_user_context(context_data): if not context_data: return "1. PROFILE: User (General).\n2. DAILY STATUS: Target Calories: 2000 kcal." profile = context_data.get('profile', {}) stats = context_data.get('stats', {}) conditions = ", ".join(profile.get('medical_conditions', [])) if profile.get('medical_conditions') else "No specific data" return f""" 1. PROFILE: User ({profile.get('name', 'General')}), Conditions: {conditions}. 2. DAILY STATUS: Target Calories: {stats.get('target_calories', 2000)} kcal, CONSUMED: {stats.get('consumed_calories', 0)} kcal. """ def get_safe_float(data, targets): """Mencari nilai float dari dict SECARA CASE-INSENSITIVE.""" data_lower = {k.lower(): v for k, v in data.items()} for t in targets: if t in data_lower: val = data_lower[t] try: if isinstance(val, (int, float)): return float(val) if isinstance(val, str): nums = re.findall(r"[-+]?\d*\.\d+|\d+", val) if nums: return float(nums[0]) except: continue return 0.0 # --- ROUTES --- @app.route('/', methods=['GET']) def home(): return jsonify({ "status": "online", "message": "GastroGuard AI Backend (Strict JSON Mode)", "endpoints": ["/analyze-text", "/analyze-image", "/chat"] }) @app.route('/analyze-text', methods=['POST']) def analyze_text(): data = request.json query = data.get('query', '') user_context = data.get('user_context', {}) if not query: return jsonify({"error": "No query provided"}), 400 if FS_CLIENT_ID and FS_CLIENT_SECRET: fs_data = search_fatsecret(query) if fs_data: return jsonify(fs_data) if not client: return jsonify(mock_analyze_food(query)) try: user_context_str = format_user_context(user_context) prompt_content = f""" SYSTEM OVERRIDE: YOU ARE A JSON-ONLY API. ROLE: Calorie Logging Engine. CONTEXT: {user_context_str} USER INPUT: "{query}" TASK: 1. Identify if the user mentioned a food. 2. If yes, ESTIMATE the nutrition facts accurately. 3. Your 'chat_response' must be friendly but SHORT. OUTPUT JSON FORMAT: {{ "analisis_emosi": {{ "status": "Neutral", "indikator": "Text analysis" }}, "chat_response": "Short confirmation text.", "data_makanan": {{ "nama_menu": "Food Name", "estimasi_berat": "e.g. 1 serving", "nutrisi": {{ "kalori": 0, "protein": 0, "karbohidrat": 0, "lemak_total": 0 }} }}, "keputusan_sistem": {{ "safety_score": "Safe/Caution/Danger", "alasan_utama": "Reason" }} }} """ response = client.models.generate_content( model="gemini-3-flash-preview", contents=prompt_content, config=types.GenerateContentConfig(response_mime_type="application/json", temperature=0.3) ) result = json.loads(clean_json_text(response.text)) food_data = result.get("data_makanan", {}) nutrisi = food_data.get("nutrisi", {}) decision = result.get("keputusan_sistem", {}) cal = get_safe_float(nutrisi, ["kalori", "calories", "energy", "kcal"]) prot = get_safe_float(nutrisi, ["protein", "protien"]) carb = get_safe_float(nutrisi, ["karbohidrat", "carbs", "carbohydrate", "karbo"]) fat = get_safe_float(nutrisi, ["lemak_total", "fat", "fats", "lemak"]) health_msg = f"[{decision.get('safety_score', 'Info')}] {decision.get('alasan_utama', '')}" mapped_result = { "reply": result.get("chat_response", "Logged."), "food_name": food_data.get("nama_menu"), "calories": cal, "protein": prot, "carbs": carb, "fat": fat, "portion": food_data.get("estimasi_berat", "1 porsi"), "health_tip": health_msg } return jsonify(mapped_result) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/analyze-image', methods=['POST']) def analyze_image(): if 'image' not in request.files: return jsonify({"error": "No image file provided"}), 400 file = request.files['image'] if file.filename == '': return jsonify({"error": "No selected file"}), 400 if not client: return jsonify({"error": "Server configuration error: No AI Key"}), 503 try: image_bytes = file.read() user_prompt = request.form.get('prompt', '').strip() user_context_raw = request.form.get('user_context', '{}') try: user_context = json.loads(user_context_raw) except: user_context = {} user_context_str = format_user_context(user_context) system_instruction_text = f""" SYSTEM OVERRIDE: YOU ARE A JSON-ONLY API. ROLE: GastroGuard AI Vision Engine. CONTEXT: {user_context_str} TASK: 1. Analyze the input image. 2. EXTRACT nutrition data (Calories, Protein, etc) - ESTIMATION IS MANDATORY. 3. RETURN ONLY JSON. OUTPUT SCHEMA (STRICT): {{ "analisis_emosi": {{ "status": "Neutral", "indikator": "Visual" }}, "chat_response": "Your answer (Max 2 sentences).", "data_makanan": {{ "nama_menu": "Food Name", "estimasi_berat": "e.g. 1 serving", "nutrisi": {{ "kalori": 0, "protein": 0, "karbohidrat": 0, "lemak_total": 0 }} }}, "keputusan_sistem": {{ "safety_score": "Safe/Caution/Danger", "alasan_utama": "Reason" }} }} """ request_contents = [ system_instruction_text, types.Part.from_bytes(data=image_bytes, mime_type=file.content_type or "image/jpeg") ] if user_prompt: request_contents.append(f"USER QUERY: {user_prompt}") response_vision = client.models.generate_content( model="gemini-3-flash-preview", contents=request_contents, config=types.GenerateContentConfig(response_mime_type="application/json", temperature=0.4) ) text_res = clean_json_text(response_vision.text) result = json.loads(text_res) food_data = result.get("data_makanan", {}) nutrisi = food_data.get("nutrisi", {}) decision = result.get("keputusan_sistem", {}) cal = get_safe_float(nutrisi, ["kalori", "calories", "energy", "kcal"]) prot = get_safe_float(nutrisi, ["protein"]) carb = get_safe_float(nutrisi, ["karbohidrat", "carbs"]) fat = get_safe_float(nutrisi, ["lemak_total", "fat"]) health_msg = f"[{decision.get('safety_score', 'Info')}] {decision.get('alasan_utama', '')}" nutrition_text = f"\n\nšŸ“Š **{food_data.get('nama_menu', 'Food')} Info:**\nšŸ”„ {int(cal)} kcal | 🄩 P: {int(prot)}g | šŸž C: {int(carb)}g | šŸ„‘ F: {int(fat)}g" final_reply = result.get("chat_response", "Food detected.") + nutrition_text mapped_result = { "reply": final_reply, "food_name": food_data.get("nama_menu", "Detected Item"), "calories": cal, "protein": prot, "carbs": carb, "fat": fat, "health_tip": health_msg } return jsonify(mapped_result) except Exception as e: traceback.print_exc() return jsonify({"error": f"Server Error: {str(e)}"}), 500 @app.route('/chat', methods=['POST']) def chat(): data = request.json message = data.get('message', '') user_context = data.get('user_context', {}) if not message: return jsonify({"reply": "Silakan ketik sesuatu..."}) if not client: return jsonify({"reply": "Server configuration error: No AI Key"}) try: user_context_str = format_user_context(user_context) # --- PROMPT DIGANTI TOTAL: MIRIP VISI (STRICT JSON) --- prompt_content = f""" SYSTEM OVERRIDE: YOU ARE A JSON-ONLY API. ROLE: Calorie & Nutrition Logging Backend. CONTEXT: {user_context_str} USER MESSAGE: "{message}" MANDATORY INSTRUCTIONS: 1. Analyze user message. 2. IF FOOD DETECTED (Intent to eat, asking calories, or just food name): - FILL 'data_makanan' with ESTIMATED values. - ESTIMATION IS MANDATORY (Do not return 0). - IGNORE verbs like "want to", just treat it as data extraction. 3. IF NO FOOD: Keep nutrition 0. 4. RETURN JSON ONLY. OUTPUT JSON SCHEMA: {{ "analisis_emosi": {{ "status": "Neutral", "indikator": "text" }}, "chat_response": "Friendly answer (Max 2 sentences).", "data_makanan": {{ "nama_menu": "Food Name", "estimasi_berat": "e.g. 1 serving", "nutrisi": {{ "kalori": 0, "protein": 0, "karbohidrat": 0, "lemak_total": 0 }} }}, "keputusan_sistem": {{ "safety_score": "Safe/Info", "alasan_utama": "Reason" }} }} """ response = client.models.generate_content( model="gemini-3-flash-preview", contents=prompt_content, config=types.GenerateContentConfig(response_mime_type="application/json", temperature=0.3) ) result = json.loads(clean_json_text(response.text)) base_reply = result.get("chat_response", "") food_data = result.get("data_makanan", {}) nutrisi = food_data.get("nutrisi", {}) decision = result.get("keputusan_sistem", {}) # --- ROBUST EXTRACTION --- cal_val = get_safe_float(nutrisi, ["kalori", "calories", "energy", "kcal"]) prot = get_safe_float(nutrisi, ["protein", "protien"]) carb = get_safe_float(nutrisi, ["karbohidrat", "carbs", "carbohydrate", "gula"]) fat = get_safe_float(nutrisi, ["lemak_total", "fat", "fats", "lemak"]) menu_name = food_data.get("nama_menu") # --- REGEX BACKUP (Jaga-jaga AI nulis angka di teks tapi lupa di JSON) --- if cal_val == 0: combined_text = base_reply + " " + decision.get("alasan_utama", "") found_cals = re.findall(r"(\d+)\s*(?:kcal|cal|calories)", combined_text, re.IGNORECASE) if found_cals: cal_val = float(found_cals[0]) if not menu_name: menu_name = "Food Detected" # --- APPEND LOGIC --- if cal_val > 0: display_name = menu_name if menu_name else "Food" # Tanda tanya jika makro lain 0 p_str = f"{int(prot)}g" if prot > 0 else "?" c_str = f"{int(carb)}g" if carb > 0 else "?" f_str = f"{int(fat)}g" if fat > 0 else "?" nutrition_text = f"\n\nšŸ“Š **{display_name} Info:**\nšŸ”„ {int(cal_val)} kcal | 🄩 P: {p_str} | šŸž C: {c_str} | šŸ„‘ F: {f_str}" final_reply = base_reply + nutrition_text else: final_reply = base_reply mapped_result = { "reply": final_reply, "food_name": menu_name, "calories": cal_val, "protein": prot, "carbs": carb, "fat": fat, "health_tip": decision.get("alasan_utama", "") } return jsonify(mapped_result) except Exception as e: print(f"Chat Error: {e}") return jsonify({"reply": f"System Error: {str(e)}"}) if __name__ == '__main__': app.run(host='0.0.0.0', port=7860, debug=True)