smartplate 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
smartplate/__init__.py ADDED
File without changes
@@ -0,0 +1,460 @@
1
+ """
2
+ advice_engine.py - 个性化建议生成模块
3
+ 基于《中国居民膳食指南 2022》的膳食参考摄入量(DRI)规则引擎
4
+ 支持用户画像参数:性别、年龄、体重、活动水平、健康目标
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ # ============================================================
10
+ # 膳食参考摄入量(DRI)基准值
11
+ # 数据来源:《中国居民膳食营养素参考摄入量(2023版)》
12
+ # ============================================================
13
+
14
+ # 基础能量需求(kcal/天),按性别和年龄段
15
+ # 轻体力活动水平
16
+ ENERGY_RDA = {
17
+ "male": {
18
+ (18, 50): 2250,
19
+ (50, 65): 2100,
20
+ (65, 80): 1950,
21
+ },
22
+ "female": {
23
+ (18, 50): 1800,
24
+ (50, 65): 1750,
25
+ (65, 80): 1600,
26
+ },
27
+ }
28
+
29
+ # 活动水平系数(乘在基础能量上)
30
+ ACTIVITY_FACTOR = {
31
+ "sedentary": 0.95,
32
+ "light": 1.0,
33
+ "moderate": 1.15,
34
+ "active": 1.3,
35
+ }
36
+
37
+ # 宏量营养素推荐供能比
38
+ MACRO_RATIO = {
39
+ "carbs": (0.50, 0.65), # 碳水供能比 50%-65%
40
+ "protein": (0.10, 0.15), # 蛋白质供能比 10%-15%
41
+ "fat": (0.20, 0.30), # 脂肪供能比 20%-30%
42
+ }
43
+
44
+ # 每克宏量营养素的热量
45
+ KCAL_PER_GRAM = {"carbs": 4, "protein": 4, "fat": 9}
46
+
47
+ # 微量元素每日推荐摄入量(18-50岁成人)
48
+ MICRO_RDA = {
49
+ "calcium_mg": 800,
50
+ "iron_mg": (12, 20), # (男, 女)
51
+ "zinc_mg": (12.5, 7.5),
52
+ "magnesium_mg": 330,
53
+ "potassium_mg": 2000,
54
+ "fiber_g": 25,
55
+ }
56
+
57
+ # 特殊人群的调整系数
58
+ GOAL_ADJUSTMENTS: dict[str, dict[str, Any]] = {
59
+ "diabetes": {
60
+ "carbs_max_per_meal": 60, # 每餐碳水上限(g)
61
+ "gi_limit": 55, # GI 上限
62
+ "gl_max_per_meal": 40, # 每餐血糖负荷(GL)上限
63
+ "fiber_target_per_meal": 8, # 每餐膳食纤维目标(g)
64
+ "prefer_low_gi": True,
65
+ "description": "糖尿病/血糖控制",
66
+ },
67
+ "weight_loss": {
68
+ "calorie_deficit": 0.75, # 热量系数(75%日常需求)
69
+ "protein_min_per_meal": 25, # 每餐蛋白质下限(g)
70
+ "fat_max_per_meal": 20, # 每餐脂肪上限(g)
71
+ "description": "减脂/体重控制",
72
+ },
73
+ "muscle_gain": {
74
+ "calorie_surplus": 1.1, # 热量系数(110%日常需求)
75
+ "protein_min_per_meal": 30, # 每餐蛋白质下限(g)
76
+ "carbs_min_per_meal": 60, # 每餐碳水下限(g)
77
+ "description": "增肌/力量训练",
78
+ },
79
+ "general": {
80
+ "description": "日常健康饮食",
81
+ },
82
+ }
83
+
84
+ # 建议知识库(按营养素和状态组织)
85
+ ADVICE_KNOWLEDGE = {
86
+ "carbs_high": "碳水化合物偏高,建议适当减少主食份量,或选择低GI替代品(如糙米代替白米、全麦面包代替白面包)。",
87
+ "carbs_low": "碳水化合物摄入偏少,运动后可适量补充复合碳水(如红薯、燕麦)。",
88
+ "protein_high": "蛋白质充足,有利于肌肉合成和饱腹感维持。",
89
+ "protein_low": "蛋白质偏低,建议增加瘦肉、鱼虾、蛋类、豆制品的摄入。",
90
+ "fat_high": "脂肪摄入偏高,建议选择清淡烹饪方式(蒸、煮、炖),减少油炸和高脂食材。",
91
+ "fat_low": "脂肪摄入偏低,适量摄入优质脂肪(坚果、橄榄油、鱼油)有益健康。",
92
+ "calories_high": "此餐热量偏高,注意控制份量,多吃蔬菜增加饱腹感。",
93
+ "calories_low": "此餐热量偏低,可适量增加主食或蛋白质食物。",
94
+ "fiber_low": "膳食纤维不足,建议增加蔬菜、全谷物、豆类的摄入。",
95
+ "fiber_high": "膳食纤维充足,有助于肠道健康和血糖稳定。",
96
+ "gl_high": "本餐血糖负荷(GL)偏高,即使单种食物GI不高,大量碳水化合物仍会显著升高血糖。建议减少主食份量或分餐食用。",
97
+ "gl_moderate": "本餐血糖负荷(GL)中等,注意搭配蛋白质和蔬菜一起食用以减缓血糖上升速度。",
98
+ "gl_low": "本餐血糖负荷(GL)较低,对血糖影响较小。",
99
+ }
100
+
101
+
102
+ # ============================================================
103
+ # 用户画像
104
+ # ============================================================
105
+ def get_user_rda(gender: str = "male", age: int = 30, activity: str = "light",
106
+ goal: str = "general") -> dict:
107
+ """
108
+ 根据用户画像计算个性化每日推荐摄入量
109
+ :param gender: 'male' 或 'female'
110
+ :param age: 年龄
111
+ :param activity: 'sedentary' / 'light' / 'moderate' / 'active'
112
+ :param goal: 'general' / 'diabetes' / 'weight_loss' / 'muscle_gain'
113
+ :return: 每日推荐摄入量字典
114
+ """
115
+ # 基础能量
116
+ base_energy = 2000
117
+ for (lo, hi), val in ENERGY_RDA.get(gender, ENERGY_RDA["male"]).items():
118
+ if lo <= age < hi:
119
+ base_energy = val
120
+ break
121
+
122
+ # 活动水平调整
123
+ act_factor = ACTIVITY_FACTOR.get(activity, 1.0)
124
+ daily_energy = base_energy * act_factor
125
+
126
+ # 目标调整
127
+ goal_config: dict[str, Any] = GOAL_ADJUSTMENTS.get(goal, GOAL_ADJUSTMENTS["general"])
128
+ if "calorie_deficit" in goal_config:
129
+ daily_energy *= goal_config["calorie_deficit"]
130
+ elif "calorie_surplus" in goal_config:
131
+ daily_energy *= goal_config["calorie_surplus"]
132
+
133
+ # 宏量营养素
134
+ carbs_kcal = daily_energy * (MACRO_RATIO["carbs"][0] + MACRO_RATIO["carbs"][1]) / 2
135
+ protein_kcal = daily_energy * (MACRO_RATIO["protein"][0] + MACRO_RATIO["protein"][1]) / 2
136
+ fat_kcal = daily_energy * (MACRO_RATIO["fat"][0] + MACRO_RATIO["fat"][1]) / 2
137
+
138
+ daily_carbs = carbs_kcal / KCAL_PER_GRAM["carbs"]
139
+ daily_protein = protein_kcal / KCAL_PER_GRAM["protein"]
140
+ daily_fat = fat_kcal / KCAL_PER_GRAM["fat"]
141
+
142
+ return {
143
+ "daily_energy_kcal": round(daily_energy),
144
+ "daily_carbs_g": round(daily_carbs, 1),
145
+ "daily_protein_g": round(daily_protein, 1),
146
+ "daily_fat_g": round(daily_fat, 1),
147
+ "daily_fiber_g": MICRO_RDA["fiber_g"],
148
+ "goal": goal,
149
+ "goal_description": goal_config.get("description", ""),
150
+ "goal_config": goal_config,
151
+ }
152
+
153
+
154
+ def per_meal_rda(rda: dict, meals_per_day: int = 3) -> dict:
155
+ """将每日 RDA 转换为每餐目标"""
156
+ return {
157
+ "energy_kcal": round(rda["daily_energy_kcal"] / meals_per_day),
158
+ "carbs_g": round(rda["daily_carbs_g"] / meals_per_day, 1),
159
+ "protein_g": round(rda["daily_protein_g"] / meals_per_day, 1),
160
+ "fat_g": round(rda["daily_fat_g"] / meals_per_day, 1),
161
+ "fiber_g": round(rda["daily_fiber_g"] / meals_per_day, 1),
162
+ }
163
+
164
+
165
+ # ============================================================
166
+ # 营养分析
167
+ # ============================================================
168
+ def analyze_nutrition(totals: dict, per_meal_targets: dict,
169
+ goal_config: dict) -> list[dict]:
170
+ """
171
+ 将本餐营养数据与每餐目标对比,输出每个指标的分析
172
+ :return: 分析结果列表,每项包含 {nutrient, label, value, target, status, percentage, message}
173
+ """
174
+ results = []
175
+
176
+ # 碳水
177
+ carbs = totals.get("carbs_g", 0)
178
+ target_carbs = per_meal_targets.get("carbs_g", 80)
179
+ # 控糖目标单独限制
180
+ if "carbs_max_per_meal" in goal_config:
181
+ target_carbs = min(target_carbs, goal_config["carbs_max_per_meal"])
182
+
183
+ carbs_ratio = carbs / max(target_carbs, 1) * 100
184
+ if carbs_ratio > 130:
185
+ status, msg_key = "high", "carbs_high"
186
+ elif carbs_ratio < 50:
187
+ status, msg_key = "low", "carbs_low"
188
+ else:
189
+ status, msg_key = "normal", ""
190
+
191
+ results.append({
192
+ "nutrient": "carbohydrate",
193
+ "label": "碳水化合物",
194
+ "value": round(carbs, 1),
195
+ "unit": "g",
196
+ "target": round(target_carbs, 1),
197
+ "status": status,
198
+ "percentage": round(carbs_ratio),
199
+ "message": ADVICE_KNOWLEDGE.get(msg_key, ""),
200
+ })
201
+
202
+ # 蛋白质
203
+ protein = totals.get("protein_g", 0)
204
+ target_protein = per_meal_targets.get("protein_g", 25)
205
+ if "protein_min_per_meal" in goal_config:
206
+ target_protein = max(target_protein, goal_config["protein_min_per_meal"])
207
+
208
+ protein_ratio = protein / max(target_protein, 1) * 100
209
+ if protein_ratio > 150:
210
+ status, msg_key = "high", "protein_high"
211
+ elif protein_ratio < 60:
212
+ status, msg_key = "low", "protein_low"
213
+ else:
214
+ status, msg_key = "normal", ""
215
+
216
+ results.append({
217
+ "nutrient": "protein",
218
+ "label": "蛋白质",
219
+ "value": round(protein, 1),
220
+ "unit": "g",
221
+ "target": round(target_protein, 1),
222
+ "status": status,
223
+ "percentage": round(protein_ratio),
224
+ "message": ADVICE_KNOWLEDGE.get(msg_key, ""),
225
+ })
226
+
227
+ # 脂肪
228
+ fat = totals.get("fat_g", 0)
229
+ target_fat = per_meal_targets.get("fat_g", 18)
230
+ if "fat_max_per_meal" in goal_config:
231
+ target_fat = goal_config["fat_max_per_meal"]
232
+
233
+ fat_ratio = fat / max(target_fat, 1) * 100
234
+ if fat_ratio > 150:
235
+ status, msg_key = "high", "fat_high"
236
+ elif fat_ratio < 30:
237
+ status, msg_key = "low", "fat_low"
238
+ else:
239
+ status, msg_key = "normal", ""
240
+
241
+ results.append({
242
+ "nutrient": "fat",
243
+ "label": "脂肪",
244
+ "value": round(fat, 1),
245
+ "unit": "g",
246
+ "target": round(target_fat, 1),
247
+ "status": status,
248
+ "percentage": round(fat_ratio),
249
+ "message": ADVICE_KNOWLEDGE.get(msg_key, ""),
250
+ })
251
+
252
+ # 热量
253
+ calories = totals.get("calories", 0)
254
+ target_calories = per_meal_targets.get("energy_kcal", 600)
255
+
256
+ cal_ratio = calories / max(target_calories, 1) * 100
257
+ if cal_ratio > 130:
258
+ status, msg_key = "high", "calories_high"
259
+ elif cal_ratio < 50:
260
+ status, msg_key = "low", "calories_low"
261
+ else:
262
+ status, msg_key = "normal", ""
263
+
264
+ results.append({
265
+ "nutrient": "calories",
266
+ "label": "热量",
267
+ "value": round(calories),
268
+ "unit": "kcal",
269
+ "target": round(target_calories),
270
+ "status": status,
271
+ "percentage": round(cal_ratio),
272
+ "message": ADVICE_KNOWLEDGE.get(msg_key, ""),
273
+ })
274
+
275
+ # 膳食纤维
276
+ fiber = totals.get("fiber_g", 0)
277
+ target_fiber = per_meal_targets.get("fiber_g", 8)
278
+ if "fiber_target_per_meal" in goal_config:
279
+ target_fiber = goal_config["fiber_target_per_meal"]
280
+
281
+ fiber_ratio = fiber / max(target_fiber, 1) * 100
282
+ if fiber_ratio > 80:
283
+ status, msg_key = "normal", "fiber_high"
284
+ else:
285
+ status, msg_key = "low", "fiber_low"
286
+
287
+ results.append({
288
+ "nutrient": "fiber",
289
+ "label": "膳食纤维",
290
+ "value": round(fiber, 1),
291
+ "unit": "g",
292
+ "target": round(target_fiber, 1),
293
+ "status": status,
294
+ "percentage": round(fiber_ratio),
295
+ "message": ADVICE_KNOWLEDGE.get(msg_key, ""),
296
+ })
297
+
298
+ # 血糖负荷(GL = GI × 碳水克数 / 100,仅控糖模式展示)
299
+ if "gl_max_per_meal" in goal_config:
300
+ gl = totals.get("glycemic_load", 0)
301
+ # 如果 totals 没有预计算 GL,用平均 GI 估算
302
+ if gl == 0 and carbs > 0:
303
+ avg_gi = totals.get("avg_gi", 50)
304
+ gl = avg_gi * carbs / 100.0
305
+ target_gl = goal_config["gl_max_per_meal"]
306
+ gl_ratio = gl / max(target_gl, 1) * 100
307
+ if gl_ratio > 130:
308
+ gl_status, gl_key = "high", "gl_high"
309
+ elif gl_ratio > 80:
310
+ gl_status, gl_key = "normal", "gl_moderate"
311
+ else:
312
+ gl_status, gl_key = "normal", "gl_low"
313
+
314
+ results.append({
315
+ "nutrient": "glycemic_load",
316
+ "label": "血糖负荷(GL)",
317
+ "value": round(gl, 1),
318
+ "unit": "",
319
+ "target": round(target_gl, 1),
320
+ "status": gl_status,
321
+ "percentage": round(gl_ratio),
322
+ "message": ADVICE_KNOWLEDGE.get(gl_key, ""),
323
+ })
324
+
325
+ return results
326
+
327
+
328
+ # ============================================================
329
+ # 建议生成(主入口)
330
+ # ============================================================
331
+ def _generate_pairing_advice(totals: dict, goal: str, goal_config: dict) -> list[str]:
332
+ """基于食物搭配原理生成针对性建议"""
333
+ tips = []
334
+ protein = totals.get("protein_g", 0)
335
+ fiber = totals.get("fiber_g", 0)
336
+ gl = totals.get("glycemic_load", 0)
337
+
338
+ if goal == "diabetes":
339
+ # 食物搭配协同效应分析
340
+ gl_limit = goal_config.get("gl_max_per_meal", 40)
341
+ if gl > gl_limit:
342
+ if protein >= 20 and fiber >= 5:
343
+ tips.append("💡 蛋白质+纤维组合可降低本餐的血糖冲击,实测餐后血糖峰值可比纯碳水饮食低30-50%。")
344
+ if fiber < 5:
345
+ tips.append("💡 搭配建议:增加一份绿叶蔬菜(如西兰花、菠菜),纤维可与碳水结合减缓吸收。")
346
+ if protein < 15:
347
+ tips.append("💡 搭配建议:增加一份瘦肉/鱼虾/豆腐,蛋白质可延缓胃排空速度。")
348
+
349
+ # 低GI替代建议
350
+ avg_gi = totals.get("avg_gi", 0)
351
+ if avg_gi > goal_config.get("gi_limit", 55):
352
+ tips.append("💡 主食替换:尝试用糙米饭(GI≈55)替代白米饭(GI≈73),或用全麦面包替代白面包,可降低升糖速度。")
353
+
354
+ # 好组合表扬
355
+ if 0 < gl <= gl_limit and fiber >= 5 and protein >= 15:
356
+ tips.append("✅ 本餐搭配合理:碳水、蛋白质、纤维均衡,有利于血糖平稳。")
357
+
358
+ return tips
359
+
360
+
361
+ def generate_advice(totals: dict, user_type: str,
362
+ gender: str = "male", age: int = 30,
363
+ activity: str = "light") -> dict:
364
+ """
365
+ 生成个性化饮食建议
366
+ :param totals: 总营养数据字典
367
+ :param user_type: 'general' / 'diabetes' / 'weight_loss' / 'muscle_gain'
368
+ :param gender: 'male' / 'female'
369
+ :param age: 年龄
370
+ :param activity: 'sedentary' / 'light' / 'moderate' / 'active'
371
+ :return: 包含建议文本和分析详情的字典
372
+ """
373
+ # 向后兼容:旧的 user_type 映射
374
+ goal_map = {
375
+ "diabetes": "diabetes",
376
+ "fitness": "muscle_gain",
377
+ "general": "general",
378
+ }
379
+ goal = goal_map.get(user_type, user_type)
380
+
381
+ # 获取用户 RDA
382
+ rda = get_user_rda(gender, age, activity, goal)
383
+ meal_targets = per_meal_rda(rda)
384
+ goal_config = rda["goal_config"]
385
+
386
+ # 逐项分析
387
+ analysis = analyze_nutrition(totals, meal_targets, goal_config)
388
+
389
+ # 生成文本建议
390
+ lines = [f"【{rda['goal_description']}】本餐分析(目标 {meal_targets['energy_kcal']} kcal/餐)"]
391
+
392
+ warnings = []
393
+ goods = []
394
+ for a in analysis:
395
+ if a["status"] == "high":
396
+ warnings.append(f"⚠️ {a['label']}: {a['value']}{a['unit']} / 目标 {a['target']}{a['unit']} ({a['percentage']}%)")
397
+ elif a["status"] == "low":
398
+ warnings.append(f"↓ {a['label']}: {a['value']}{a['unit']} / 目标 {a['target']}{a['unit']} ({a['percentage']}%)")
399
+ else:
400
+ goods.append(f"✓ {a['label']}: {a['value']}{a['unit']}(达标)")
401
+
402
+ if goods:
403
+ lines.extend(goods)
404
+ if warnings:
405
+ lines.append("")
406
+ lines.extend(warnings)
407
+
408
+ # 针对性建议
409
+ specific_advice = []
410
+ for a in analysis:
411
+ if a["message"]:
412
+ specific_advice.append(f"💡 {a['message']}")
413
+
414
+ # 动态食物搭配建议(基于实际营养构成)
415
+ pairing_advice = _generate_pairing_advice(totals, goal, goal_config)
416
+ if pairing_advice:
417
+ specific_advice.extend(pairing_advice)
418
+
419
+ if goal == "diabetes":
420
+ # 动态进餐顺序建议
421
+ carbs_val = totals.get("carbs_g", 0)
422
+ protein_val = totals.get("protein_g", 0)
423
+ fiber_val = totals.get("fiber_g", 0)
424
+ fat_val = totals.get("fat_g", 0)
425
+
426
+ if carbs_val > goal_config.get("carbs_max_per_meal", 60):
427
+ if protein_val >= 15 and fiber_val >= 4:
428
+ specific_advice.append("💡 本餐碳水较高但蛋白质和纤维充足:先吃蔬菜→再吃蛋白质→最后吃主食,可降低餐后血糖峰值30-40%。")
429
+ elif fiber_val >= 4:
430
+ specific_advice.append("💡 先吃蔬菜摄入膳食纤维,再吃主食,纤维可延缓糖分吸收、平稳血糖。")
431
+ elif protein_val >= 15:
432
+ specific_advice.append("💡 先吃蛋白质食物,再吃主食。蛋白质可延缓胃排空,减缓血糖上升速度。")
433
+ else:
434
+ specific_advice.append("💡 本餐碳水偏高且缺乏蛋白质和纤维配合,建议增加蔬菜和瘦肉来搭配主食,降低血糖冲击。")
435
+ elif fiber_val >= 5:
436
+ specific_advice.append("💡 本餐膳食纤维充足,搭配碳水一起食用可有效延缓血糖上升。")
437
+ if fat_val > 20:
438
+ specific_advice.append("💡 高脂肪会延迟但延长血糖升高,控糖人群应注意脂肪总摄入量。")
439
+ elif goal == "weight_loss":
440
+ specific_advice.append("💡 减脂建议:细嚼慢咽,每餐不少于20分钟,增加饱腹感。")
441
+ elif goal == "muscle_gain":
442
+ specific_advice.append("💡 增肌建议:训练后30分钟内补充蛋白质+碳水,促进肌肉合成。")
443
+
444
+ if specific_advice:
445
+ lines.append("")
446
+ lines.extend(specific_advice)
447
+
448
+ return {
449
+ "advice_text": "\n".join(lines),
450
+ "user_profile": {
451
+ "gender": gender,
452
+ "age": age,
453
+ "activity": activity,
454
+ "goal": goal,
455
+ "goal_description": rda["goal_description"],
456
+ },
457
+ "daily_rda": rda,
458
+ "per_meal_targets": meal_targets,
459
+ "analysis": analysis,
460
+ }