GastroGuard / app.py
RantoG's picture
Update app.py
ede3f3f verified
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)