Files
c/backend/app/routes/recommend_routes.py
刘正航 b5237f9038 1
2026-04-21 22:45:19 +08:00

315 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])