Spaces:
Sleeping
Sleeping
| 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 = ( | |
| "<p>You also asked to receive weekly fitness ideas and updates. " | |
| "That preference was recorded on the request.</p>" | |
| ) | |
| text_body = f"""Hi {greeting_name}, | |
| Here is your DailyMate personalized fitness plan. | |
| {plan_text} | |
| {marketing_note_text} | |
| Stay consistent, | |
| DailyMate | |
| """ | |
| html_body = f""" | |
| <div style="font-family: Arial, Helvetica, sans-serif; line-height: 1.6; color: #111;"> | |
| <h2>Your DailyMate plan — {subject_goal}</h2> | |
| <p>Hi {greeting_name},</p> | |
| <p>Here is your DailyMate personalized fitness plan.</p> | |
| <pre style="white-space: pre-wrap; font-family: Arial, Helvetica, sans-serif; background: #f6f6f6; padding: 16px; border-radius: 8px;">{plan_text}</pre> | |
| {marketing_note_html} | |
| <p>Stay consistent,<br>DailyMate</p> | |
| </div> | |
| """ | |
| payload = { | |
| "from": "DailyMate <onboarding@resend.dev>", | |
| "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}") | |
| def home(): | |
| return send_from_directory("site", "index.html") | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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) |