from statistics import mean from flask import Blueprint, current_app, request from flask_jwt_extended import jwt_required from app.extensions import db from app.ml.rf_recommender import RandomForestDietRecommender, merge_profile_with_history from app.models import DietStatus, RecommendationLog, Recipe from app.utils.auth import current_user from app.utils.response import fail, ok recommend_bp = Blueprint("recommend", __name__) def _build_profile(user, latest_status): profile = { "age": user.age, "height_cm": user.height_cm, "goal": user.goal, "occupation": user.occupation, "weight": 65, "body_fat": 20, "exercise_kcal": 300, "intake_kcal": 1800, } if latest_status: profile.update( { "weight": latest_status.weight, "body_fat": latest_status.body_fat, "exercise_kcal": latest_status.exercise_kcal, "intake_kcal": latest_status.intake_kcal, } ) return profile def _goal_adjust(goal: str, recipe: Recipe) -> float: if goal == "lose_fat": return recipe.protein * 0.8 - recipe.fat * 0.4 - max(recipe.calories - 500, 0) * 0.04 if goal == "gain_muscle": return recipe.protein * 1.0 + recipe.carbs * 0.25 if goal == "keto": return recipe.fat * 0.5 - recipe.carbs * 0.8 return recipe.protein * 0.3 + recipe.fiber * 1.2 def _occupation_adjust(occupation: str, recipe: Recipe) -> float: occupation = (occupation or "").lower() if occupation in {"developer", "office"}: return recipe.fiber * 0.9 + recipe.protein * 0.2 if occupation in {"fitness", "manual"}: return recipe.protein * 0.6 + recipe.carbs * 0.2 if occupation in {"teacher", "student"}: return recipe.carbs * 0.15 + recipe.protein * 0.3 return recipe.protein * 0.25 def _health_adjust(profile: dict, recipe: Recipe) -> float: score = 70.0 body_fat = float(profile.get("body_fat", 20)) intake_kcal = float(profile.get("intake_kcal", 1800)) exercise_kcal = float(profile.get("exercise_kcal", 300)) if body_fat > 28: score += recipe.protein * 0.5 score -= max(recipe.calories - 480, 0) * 0.05 elif body_fat < 14: score += recipe.carbs * 0.15 + recipe.fat * 0.1 if intake_kcal > exercise_kcal + 1700: score -= max(recipe.calories - 450, 0) * 0.04 else: score += recipe.protein * 0.3 score += min(recipe.fiber * 1.0, 10) return round(score, 2) def _serialize_with_score(recipes): return [ { **item[0].to_dict(), "score": round(float(item[1]), 2), "reason": item[2], } for item in recipes ] def _save_log(user_id: int, rec_type: str, payload: dict) -> None: log = RecommendationLog(user_id=user_id, rec_type=rec_type, payload=payload) db.session.add(log) db.session.commit() @recommend_bp.get("/current") @jwt_required() def recommend_current_status(): user = current_user() if not user: return fail("用户不存在", 404) top_k = min(max(int(request.args.get("top_k", 5) or 5), 1), 20) latest = ( DietStatus.query.filter_by(user_id=user.id) .order_by(DietStatus.recorded_at.desc(), DietStatus.id.desc()) .first() ) history = ( DietStatus.query.filter_by(user_id=user.id) .order_by(DietStatus.recorded_at.desc(), DietStatus.id.desc()) .limit(14) .all() ) recipes = Recipe.query.order_by(Recipe.id.asc()).all() if not recipes: return fail("当前没有可推荐食谱,请先导入食谱", 400) profile = _build_profile(user, latest) profile = merge_profile_with_history(profile, history) recommender = RandomForestDietRecommender(current_app.config["MODEL_PATH"]) items = recommender.recommend(profile, recipes, top_k=top_k) response_data = { "type": "current_status_rf", "profile": profile, "items": items, } _save_log(user.id, "current_status_rf", response_data) return ok(response_data) @recommend_bp.get("/health") @jwt_required() def recommend_by_health(): user = current_user() if not user: return fail("用户不存在", 404) top_k = min(max(int(request.args.get("top_k", 5) or 5), 1), 20) latest = ( DietStatus.query.filter_by(user_id=user.id) .order_by(DietStatus.recorded_at.desc(), DietStatus.id.desc()) .first() ) profile = _build_profile(user, latest) recipes = Recipe.query.order_by(Recipe.id.asc()).all() if not recipes: return fail("当前没有可推荐食谱,请先导入食谱", 400) scored = [] for recipe in recipes: score = _health_adjust(profile, recipe) reason = "结合当前体脂和摄入消耗状态,推荐更合理的营养结构。" scored.append((recipe, score, reason)) scored.sort(key=lambda row: row[1], reverse=True) items = _serialize_with_score(scored[:top_k]) response_data = { "type": "health_state", "profile": profile, "items": items, } _save_log(user.id, "health_state", response_data) return ok(response_data) @recommend_bp.get("/plan/history") @jwt_required() def recommend_plan_by_history(): user = current_user() if not user: return fail("用户不存在", 404) top_k = min(max(int(request.args.get("top_k", 5) or 5), 1), 20) history = ( DietStatus.query.filter_by(user_id=user.id) .order_by(DietStatus.recorded_at.desc(), DietStatus.id.desc()) .limit(30) .all() ) if len(history) < 3: return fail("历史数据太少,至少记录3天后可用该功能", 400) avg_weight = mean([h.weight for h in history]) early_weight = history[-1].weight latest_weight = history[0].weight weight_trend = latest_weight - early_weight avg_intake = mean([h.intake_kcal for h in history]) avg_exercise = mean([h.exercise_kcal for h in history]) cal_limit = 520 strategy = "维持计划" if weight_trend > 0.8 or avg_intake > avg_exercise + 1700: cal_limit = 440 strategy = "减脂优先计划" elif weight_trend < -0.8: cal_limit = 620 strategy = "增肌恢复计划" recipes = Recipe.query.filter(Recipe.calories <= cal_limit).order_by(Recipe.protein.desc()).limit(top_k).all() items = [] for recipe in recipes: items.append( { **recipe.to_dict(), "score": round(recipe.protein * 2 - recipe.fat * 0.3, 2), "reason": f"基于历史趋势({strategy}),优先推荐该热量区间食谱。", } ) response_data = { "type": "history_plan", "strategy": strategy, "metrics": { "avg_weight": round(avg_weight, 2), "weight_trend": round(weight_trend, 2), "avg_intake": round(avg_intake, 1), "avg_exercise": round(avg_exercise, 1), }, "items": items, } _save_log(user.id, "history_plan", response_data) return ok(response_data) @recommend_bp.get("/plan/goal") @jwt_required() def recommend_plan_by_goal(): user = current_user() if not user: return fail("用户不存在", 404) top_k = min(max(int(request.args.get("top_k", 5) or 5), 1), 20) goal = request.args.get("goal") or user.goal recipes = Recipe.query.order_by(Recipe.id.asc()).all() if not recipes: return fail("当前没有可推荐食谱,请先导入食谱", 400) scored = [] for recipe in recipes: score = 60 + _goal_adjust(goal, recipe) reason = f"根据目标({goal})筛选营养比例更匹配的食谱。" scored.append((recipe, score, reason)) scored.sort(key=lambda x: x[1], reverse=True) items = _serialize_with_score(scored[:top_k]) response_data = { "type": "future_goal_plan", "goal": goal, "items": items, } _save_log(user.id, "future_goal_plan", response_data) return ok(response_data) @recommend_bp.get("/plan/occupation") @jwt_required() def recommend_plan_by_occupation(): user = current_user() if not user: return fail("用户不存在", 404) top_k = min(max(int(request.args.get("top_k", 5) or 5), 1), 20) occupation = request.args.get("occupation") or user.occupation recipes = Recipe.query.order_by(Recipe.id.asc()).all() if not recipes: return fail("当前没有可推荐食谱,请先导入食谱", 400) scored = [] for recipe in recipes: score = 65 + _occupation_adjust(occupation, recipe) reason = f"结合职业({occupation})的能量消耗特点进行推荐。" scored.append((recipe, score, reason)) scored.sort(key=lambda x: x[1], reverse=True) items = _serialize_with_score(scored[:top_k]) response_data = { "type": "occupation_plan", "occupation": occupation, "items": items, } _save_log(user.id, "occupation_plan", response_data) return ok(response_data) @recommend_bp.get("/logs") @jwt_required() def my_recommend_logs(): user = current_user() if not user: return fail("用户不存在", 404) limit = min(max(int(request.args.get("limit", 20) or 20), 1), 100) rows = ( RecommendationLog.query.filter_by(user_id=user.id) .order_by(RecommendationLog.id.desc()) .limit(limit) .all() ) return ok([item.to_dict() for item in rows])