import json import os import traceback import requests from flask import Flask, jsonify, request, send_from_directory app = Flask(__name__, static_folder="site") HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions" RESEND_API_URL = "https://api.resend.com/emails" TEXT_MODEL = os.getenv("TEXT_MODEL", "Qwen/Qwen2.5-7B-Instruct") VISION_MODEL = os.getenv("VISION_MODEL", "CohereLabs/aya-vision-32b:cohere") def get_hf_token(): return ( os.getenv("HF_TOKEN", "").strip() or os.getenv("HF_API_KEY", "").strip() ) def get_resend_api_key(): return os.getenv("RESEND_API_KEY", "").strip() def clean_value(value, fallback="not specified"): if value is None: return fallback text = str(value).strip() if not text or text.lower() in {"unknown", "all", "prefer not to say", "not specified"}: return fallback return text def require_hf_token(): hf_token_raw = os.getenv("HF_TOKEN", "") hf_api_key_raw = os.getenv("HF_API_KEY", "") hf_token_exists = bool(hf_token_raw.strip()) hf_api_key_exists = bool(hf_api_key_raw.strip()) print("HF_TOKEN exists:", hf_token_exists, flush=True) print("HF_API_KEY exists:", hf_api_key_exists, flush=True) if not (hf_token_exists or hf_api_key_exists): raise ValueError( f"DEBUG_TOKEN_CHECK | HF_TOKEN_exists={hf_token_exists} | " f"HF_API_KEY_exists={hf_api_key_exists}" ) def hf_chat_completion(model, messages, max_tokens=700, temperature=0.3): require_hf_token() token = get_hf_token() response = requests.post( HF_ROUTER_URL, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={ "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, }, timeout=180, ) if not response.ok: raise ValueError(f"HF router error {response.status_code}: {response.text}") data = response.json() choices = data.get("choices", []) if not choices: return "" message = choices[0].get("message", {}) content = message.get("content", "") if isinstance(content, list): parts = [] for item in content: if isinstance(item, dict) and item.get("type") == "text": parts.append(item.get("text", "")) return "\n".join(part for part in parts if part).strip() return str(content).strip() def build_prompt(data): goal = clean_value(data.get("goal"), "general fitness") level = clean_value(data.get("level"), "any level") training_location = clean_value(data.get("training_location"), "anywhere") workout_time = clean_value(data.get("workout_time"), "30") age_range = clean_value(data.get("age"), "not specified") sex = clean_value(data.get("sex"), "not specified") height_range = clean_value(data.get("height_range"), "not specified") weight_range = clean_value(data.get("weight_range"), "not specified") extra_notes = clean_value(data.get("extra_notes"), "none") estimated_bmi = data.get("estimated_bmi") bmi_text = "not available" if estimated_bmi not in (None, "", "unknown"): bmi_text = str(estimated_bmi) return f""" You are a fitness recommendation assistant for a website called DailyMate. Create a practical, structured, personalized 7-day fitness plan. User profile: - Goal: {goal} - Experience level: {level} - Training location: {training_location} - Minutes available per workout: {workout_time} - Age range: {age_range} - Sex: {sex} - Height range: {height_range} - Weight range: {weight_range} - Estimated BMI: {bmi_text} - Additional details: {extra_notes} Important instructions: - Use the structured profile above in a meaningful way. - If estimated BMI suggests the person may be overweight or underweight, adjust intensity, recovery, and nutrition tone sensibly. - Treat BMI only as a rough support signal, not as a diagnosis. - Respect any limitations, allergies, equipment, pain, or injuries mentioned in Additional details. - Do not give medical advice. - Do not diagnose. - Do not use generic motivational filler. - Do not add disclaimers. - Do not say "Okay User" or similar chatbot phrases. - Keep the plan realistic for the user's likely level and body profile. - Make the weekly plan actually vary across the week. - Include nutrition guidance and recovery advice, not only workouts. Return the answer in this exact structure: Overview: Write 2 short sentences describing the overall direction of the program. 7-Day Plan: Monday: ... Tuesday: ... Wednesday: ... Thursday: ... Friday: ... Saturday: ... Sunday: ... Nutrition Guidance: Write 2-4 sentences with practical nutrition guidance based on the user's goal, estimated BMI, sex, weight range, and any food-related notes. Recovery Advice: Write 2-4 sentences with realistic recovery advice based on the user's level, age range, and limitations. Summary: Write 2 short sentences summarizing the whole weekly plan and what the user should focus on. Formatting rules: - Plain text only - No markdown - No bullet symbols except the weekday labels - No asterisks - Keep it concise but useful """.strip() def build_support_prompt(message, history, context): goal = clean_value(context.get("goal"), "general fitness") level = clean_value(context.get("level"), "any level") training_location = clean_value(context.get("training_location"), "anywhere") workout_time = clean_value(context.get("workout_time"), "not specified") age_range = clean_value(context.get("age_range"), "not specified") sex = clean_value(context.get("sex"), "not specified") height_range = clean_value(context.get("height_range"), "not specified") weight_range = clean_value(context.get("weight_range"), "not specified") extra_notes = clean_value(context.get("extra_notes"), "none") estimated_bmi = context.get("estimated_bmi") latest_plan = context.get("latest_plan") or {} bmi_text = "not available" if estimated_bmi not in (None, "", "unknown"): bmi_text = str(estimated_bmi) history_lines = [] for item in history[-8:]: role = str(item.get("role", "")).strip().lower() content = str(item.get("content", "")).strip() if role in {"user", "assistant"} and content: history_lines.append(f"{role.capitalize()}: {content}") history_block = "\n".join(history_lines) if history_lines else "No previous chat history." latest_plan_text = "No generated plan available yet." if latest_plan: latest_plan_text = f""" Goal label: {latest_plan.get('goalLabel', 'General fitness')} Overview: {latest_plan.get('overview', '')} 7-Day Plan: {' | '.join(latest_plan.get('weekly', []))} Nutrition Guidance: {latest_plan.get('nutrition', '')} Recovery Advice: {latest_plan.get('recovery', '')} Summary: {latest_plan.get('summary', '')} """.strip() return f""" You are DailyMate Support, a focused assistant for a fitness planning website. Your job: - Answer only DailyMate-related support questions and simple fitness-plan guidance. - Help with using the app, understanding generated plans, rest days, workout swaps, time limits, training location changes, and beginner-friendly clarifications. - Use the user's current planner context when useful. - If the user asks for something unrelated to DailyMate, briefly say that you only handle DailyMate support and fitness-plan questions. - Do not give medical advice. - Do not diagnose. - Keep answers practical, clear, and short. - Usually answer in 2 to 5 sentences. - If a plan has not been generated yet, say so when relevant. - If the question is about replacing an exercise, suggest a reasonable equivalent based on home/gym context. - If asked whether food or a habit is “good or bad,” give a balanced practical answer instead of moralizing. Current planner context: - Goal: {goal} - Experience level: {level} - Training location: {training_location} - Workout time: {workout_time} - Age range: {age_range} - Sex: {sex} - Height range: {height_range} - Weight range: {weight_range} - Estimated BMI: {bmi_text} - Additional details: {extra_notes} Latest generated plan: {latest_plan_text} Recent chat history: {history_block} Current user question: {message} Answer as DailyMate Support: """.strip() def build_meal_analysis_prompt(): return """ You are analyzing a food photo for a fitness website called DailyMate. This is a STRICT visual recognition task. Rules: - Only describe food that is actually visible in the image. - Never invent ingredients, side dishes, sauces, or extra foods that cannot be clearly seen. - If the image shows only one obvious food, return only one item. - If the image shows multiple clearly visible foods, split them into separate items. - If you are unsure what a food is, use a cautious label like "unknown pastry", "unknown fruit", or "unknown food item". - Do not assume this is a balanced meal. - Do not add foods from memory or from typical meal patterns. - Focus on what is visibly present, not what might normally come with it. - Estimate calories conservatively and approximately. - Base calorie estimates on visible portion size only. - If the image is unclear, return fewer items rather than guessing extra items. Healthiness labeling rules: - "generally healthy" = mostly whole/minimally processed food, reasonable portion, not obviously heavy in sugar/frying/cream - "moderately healthy" = mixed or neutral, okay in moderation, somewhat calorie-dense or processed - "occasional / heavier choice" = very sugary, fried, pastry-like, creamy, fast-food-like, or clearly calorie-dense Return only valid JSON in exactly this structure: { "visible_food_summary": "short literal description of what is visibly in the image", "items": [ { "name": "food item name", "estimated_calories": 0, "note": "short portion-based reason" } ], "total_estimated_calories": 0, "healthiness_label": "generally healthy", "summary": "Two short sentences." } Important output rules: - The visible_food_summary must literally describe what is seen. - The items list must match the visible_food_summary. - If the image appears to show bananas, do not output chicken or vegetables. - If the image appears to show donuts, do not output chicken or vegetables. - If the image appears to show pasta only, do not invent steak or salad. - If the image appears to show a burger and fries, do not output donut or pastry. - Do not include markdown. - Do not include any text before or after the JSON. """.strip() def try_parse_meal_json(text): raw = str(text or "").strip() if not raw: return None if raw.startswith("```"): raw = raw.strip("`") raw = raw.replace("json", "", 1).strip() try: return json.loads(raw) except json.JSONDecodeError: pass start = raw.find("{") end = raw.rfind("}") if start != -1 and end != -1 and end > start: try: return json.loads(raw[start:end + 1]) except json.JSONDecodeError: return None return None def normalize_meal_result(parsed): if not isinstance(parsed, dict): return { "visible_food_summary": "Could not clearly identify the visible food.", "items": [], "total_estimated_calories": 0, "healthiness_label": "moderately healthy", "summary": "The meal could not be estimated clearly from the image, so treat this as a rough visual guess." } items = parsed.get("items", []) if not isinstance(items, list): items = [] normalized_items = [] for item in items[:8]: if not isinstance(item, dict): continue name = str(item.get("name", "")).strip() or "Food item" note = str(item.get("note", "")).strip() estimated_calories = item.get("estimated_calories", 0) try: estimated_calories = int(round(float(estimated_calories))) except (TypeError, ValueError): estimated_calories = 0 normalized_items.append({ "name": name, "estimated_calories": max(0, estimated_calories), "note": note }) total_estimated_calories = parsed.get("total_estimated_calories", 0) try: total_estimated_calories = int(round(float(total_estimated_calories))) except (TypeError, ValueError): total_estimated_calories = sum(item["estimated_calories"] for item in normalized_items) healthiness_label = str(parsed.get("healthiness_label", "moderately healthy")).strip().lower() if healthiness_label not in { "generally healthy", "moderately healthy", "occasional / heavier choice" }: healthiness_label = "moderately healthy" summary = str(parsed.get("summary", "")).strip() if not summary: summary = "This is a rough meal estimate based on visible ingredients. Hidden oils, sauces, and true portion size can change the actual calories." visible_food_summary = str(parsed.get("visible_food_summary", "")).strip() if not visible_food_summary: visible_food_summary = "Visible food items were estimated from the image." return { "visible_food_summary": visible_food_summary, "items": normalized_items, "total_estimated_calories": max(0, total_estimated_calories), "healthiness_label": healthiness_label, "summary": summary } def validate_email_payload(data): email = str(data.get("email", "")).strip() plan_text = str(data.get("plan_text", "")).strip() goal = str(data.get("goal", "Fitness")).strip() name = str(data.get("name", "")).strip() weekly_ideas_opt_in = bool(data.get("weekly_ideas_opt_in", False)) if not email: return False, "Email address is required.", None if "@" not in email or "." not in email: return False, "Please provide a valid email address.", None if not plan_text: return False, "No plan content was provided for email sending.", None return True, "", { "email": email, "plan_text": plan_text, "goal": goal or "Fitness", "name": name, "weekly_ideas_opt_in": weekly_ideas_opt_in } def send_plan_email(recipient_email, recipient_name, goal, plan_text, weekly_ideas_opt_in=False): resend_api_key = get_resend_api_key() if not resend_api_key: raise ValueError("RESEND_API_KEY is missing. Add it as a Secret in your Hugging Face Space settings.") subject_goal = goal if goal else "Fitness" greeting_name = recipient_name if recipient_name else "there" marketing_note_text = "" marketing_note_html = "" if weekly_ideas_opt_in: marketing_note_text = ( "\nYou also asked to receive weekly fitness ideas and updates. " "That preference was recorded on the request.\n" ) marketing_note_html = ( "

You also asked to receive weekly fitness ideas and updates. " "That preference was recorded on the request.

" ) text_body = f"""Hi {greeting_name}, Here is your DailyMate personalized fitness plan. {plan_text} {marketing_note_text} Stay consistent, DailyMate """ html_body = f"""

Your DailyMate plan — {subject_goal}

Hi {greeting_name},

Here is your DailyMate personalized fitness plan.

{plan_text}
{marketing_note_html}

Stay consistent,
DailyMate

""" payload = { "from": "DailyMate ", "to": [recipient_email], "subject": f"Your DailyMate plan — {subject_goal}", "text": text_body, "html": html_body, } response = requests.post( RESEND_API_URL, headers={ "Authorization": f"Bearer {resend_api_key}", "Content-Type": "application/json", }, json=payload, timeout=30, ) if not response.ok: raise ValueError(f"Resend API error {response.status_code}: {response.text}") @app.route("/") def home(): return send_from_directory("site", "index.html") @app.route("/recommend", methods=["POST"]) def recommend(): try: data = request.get_json(force=True) prompt = build_prompt(data) recommendation = hf_chat_completion( model=TEXT_MODEL, messages=[ {"role": "system", "content": "You are DailyMate's fitness planning assistant."}, {"role": "user", "content": prompt}, ], max_tokens=900, temperature=0.5, ) return jsonify({ "recommendation": recommendation or "No response from model." }) except requests.exceptions.Timeout: return jsonify({ "error": "AI took too long to respond. Please try again." }), 500 except requests.exceptions.HTTPError as exc: traceback.print_exc() print("RECOMMEND HTTP ERROR:", repr(exc), flush=True) return jsonify({ "error": f"Model API returned an HTTP error: {exc}" }), 500 except Exception as exc: traceback.print_exc() print("RECOMMEND ERROR:", repr(exc), flush=True) return jsonify({ "error": str(exc) }), 500 @app.route("/support-chat", methods=["POST"]) def support_chat(): try: data = request.get_json(force=True) message = str(data.get("message", "")).strip() history = data.get("history", []) context = data.get("context", {}) if not message: return jsonify({"error": "Message is required."}), 400 if not isinstance(history, list): history = [] if not isinstance(context, dict): context = {} prompt = build_support_prompt(message, history, context) reply = hf_chat_completion( model=TEXT_MODEL, messages=[ {"role": "system", "content": "You are DailyMate Support."}, {"role": "user", "content": prompt}, ], max_tokens=500, temperature=0.3, ) if not reply: reply = "Sorry, I could not generate a support answer right now." return jsonify({ "reply": reply }) except requests.exceptions.Timeout: return jsonify({ "error": "DailyMate support took too long to respond. Please try again." }), 500 except requests.exceptions.HTTPError as exc: traceback.print_exc() print("SUPPORT HTTP ERROR:", repr(exc), flush=True) return jsonify({ "error": f"Model API returned an HTTP error: {exc}" }), 500 except Exception as exc: traceback.print_exc() print("SUPPORT ERROR:", repr(exc), flush=True) return jsonify({ "error": str(exc) }), 500 @app.route("/analyze-meal", methods=["POST"]) def analyze_meal(): try: data = request.get_json(force=True) image_base64 = str(data.get("image_base64", "")).strip() if not image_base64: return jsonify({"error": "Meal image is required."}), 400 prompt = build_meal_analysis_prompt() image_data_url = f"data:image/jpeg;base64,{image_base64}" raw_response = hf_chat_completion( model=VISION_MODEL, messages=[ { "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": image_data_url}}, ], } ], max_tokens=700, temperature=0.1, ) parsed = try_parse_meal_json(raw_response) normalized = normalize_meal_result(parsed) return jsonify(normalized) except requests.exceptions.Timeout: return jsonify({ "error": "Meal analysis took too long to respond. Try a smaller image or try again." }), 500 except requests.exceptions.HTTPError as exc: traceback.print_exc() print("MEAL HTTP ERROR:", repr(exc), flush=True) return jsonify({ "error": f"Model API returned an HTTP error: {exc}" }), 500 except Exception as exc: traceback.print_exc() print("MEAL ERROR:", repr(exc), flush=True) return jsonify({ "error": str(exc) }), 500 @app.route("/send-plan", methods=["POST"]) def send_plan(): try: data = request.get_json(force=True) is_valid, error_message, cleaned = validate_email_payload(data) if not is_valid: return jsonify({"error": error_message}), 400 send_plan_email( recipient_email=cleaned["email"], recipient_name=cleaned["name"], goal=cleaned["goal"], plan_text=cleaned["plan_text"], weekly_ideas_opt_in=cleaned["weekly_ideas_opt_in"] ) message = f"Your plan was sent to {cleaned['email']}." if cleaned["weekly_ideas_opt_in"]: message += " Weekly fitness ideas preference was included." return jsonify({ "message": message }) except ValueError as exc: return jsonify({ "error": str(exc) }), 500 except Exception as exc: return jsonify({ "error": f"Unexpected error while sending email: {exc}" }), 500 @app.route("/debug-env") def debug_env(): return jsonify({ "HF_TOKEN_exists": bool(os.getenv("HF_TOKEN")), "HF_API_KEY_exists": bool(os.getenv("HF_API_KEY")), "TEXT_MODEL": os.getenv("TEXT_MODEL", "Qwen/Qwen2.5-7B-Instruct"), "VISION_MODEL": os.getenv("VISION_MODEL", "CohereLabs/aya-vision-32b:cohere"), "HF_TOKEN_length": len(os.getenv("HF_TOKEN", "").strip()), "HF_API_KEY_length": len(os.getenv("HF_API_KEY", "").strip()), "RESEND_API_KEY_exists": bool(os.getenv("RESEND_API_KEY")), "RESEND_API_KEY_length": len(os.getenv("RESEND_API_KEY", "").strip()) }) if __name__ == "__main__": port = int(os.getenv("PORT", "7860")) app.run(host="0.0.0.0", port=port)