Spaces:
Sleeping
Sleeping
| 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 --- | |
| def home(): | |
| return jsonify({ | |
| "status": "online", | |
| "message": "GastroGuard AI Backend (Strict JSON Mode)", | |
| "endpoints": ["/analyze-text", "/analyze-image", "/chat"] | |
| }) | |
| 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 | |
| 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 | |
| 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) |