meal.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import uuid
  2. import json
  3. import logging
  4. import re
  5. from fastapi import APIRouter
  6. from app.models.meal import (
  7. FoodRecognizeRequest,
  8. FoodRecognizeResponse,
  9. FoodsItem,
  10. MenuGenerateRequest,
  11. MenuGenerateResponse,
  12. )
  13. from app.config import settings
  14. from app.prompt_service import get_prompt
  15. logger = logging.getLogger(__name__)
  16. router = APIRouter(prefix="/api/v1", tags=["meal"])
  17. FOOD_RECOGNIZE_TEMPLATE = """请识别以下图片中的主要食材(3-8种),按置信度排序。
  18. 返回 JSON 数组格式,不要包含其他内容:
  19. [{{"name": "食材名称", "confidence": 0.95, "category": "蔬菜/水果/肉禽/水产/蛋奶/谷物/调味/其他"}}]
  20. 图片链接: {image_url}
  21. 要求: 只返回 JSON 数组,不要有任何其他文字。"""
  22. MENU_GENERATE_TEMPLATE = """请为{participant_count}人生成{date}的一日三餐菜单。
  23. 可用食材: {foods_text}
  24. 用餐人数: {participant_count}人
  25. {constraints_text}
  26. 请返回 JSON 格式:
  27. {{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "做法一句话", "nutrition": {{"calories": 200}}}}]}}]}}
  28. 要求:
  29. 1. 午餐和晚餐按上方约束给出的菜数配菜(D(N)=4+max(0,N-3) 道菜 + 1 道汤,汤恒 1 且不计入菜数、只加菜不加汤),早餐维持 1-2 道
  30. 2. 食材用量按{participant_count}人份计算
  31. 3. 营养均衡,考虑健康目标
  32. 4. 只用提供的食材
  33. 5. 每道菜用 type 字段标注 dish(菜)或 soup(汤),汤恰好 1 道且排在最后
  34. 6. 只返回 JSON,不要其他文字"""
  35. @router.post("/food/recognize", response_model=FoodRecognizeResponse)
  36. async def recognize_food(req: FoodRecognizeRequest):
  37. """食材识别: 上传图片 URL, 返回识别食材列表"""
  38. trace_id = str(uuid.uuid4())
  39. try:
  40. from langchain_openai import ChatOpenAI
  41. from langchain_core.messages import HumanMessage, SystemMessage
  42. llm = ChatOpenAI(
  43. model=settings.llm_model,
  44. api_key=settings.llm_api_key,
  45. base_url=settings.llm_base_url,
  46. temperature=0,
  47. )
  48. role = await get_prompt("food_recognize_role") or "你是一个专业的食材识别助手。"
  49. template = await get_prompt("food_recognize") or FOOD_RECOGNIZE_TEMPLATE
  50. try:
  51. prompt = template.format(image_url=req.image_url)
  52. except (KeyError, IndexError, ValueError):
  53. logger.warning("Java 配置的 food_recognize 模板缺少占位符,回退本地模板")
  54. prompt = FOOD_RECOGNIZE_TEMPLATE.format(image_url=req.image_url)
  55. response = llm.invoke([
  56. SystemMessage(content=role),
  57. HumanMessage(content=prompt),
  58. ])
  59. text = response.content.strip()
  60. try:
  61. foods = json.loads(text)
  62. except json.JSONDecodeError:
  63. match = re.search(r'\[[\s\S]*\]', text)
  64. foods = json.loads(match.group()) if match else []
  65. foods_list = []
  66. for item in foods:
  67. if isinstance(item, dict):
  68. foods_list.append(FoodsItem(
  69. name=item.get("name", "未知"),
  70. confidence=float(item.get("confidence", 0.5)),
  71. category=item.get("category", "other"),
  72. ))
  73. return FoodRecognizeResponse(foods=foods_list, raw_response=text, trace_id=trace_id)
  74. except Exception as e:
  75. logger.error("食材识别失败: %s", e, exc_info=True)
  76. return FoodRecognizeResponse(foods=[], trace_id=trace_id)
  77. @router.post("/menu/generate", response_model=MenuGenerateResponse)
  78. async def generate_menu(req: MenuGenerateRequest):
  79. """菜单生成: 根据食材和用餐人数生成一日三餐菜单"""
  80. trace_id = str(uuid.uuid4())
  81. try:
  82. from langchain_openai import ChatOpenAI
  83. from langchain_core.messages import HumanMessage, SystemMessage
  84. try:
  85. selected_foods = json.loads(req.selected_foods) if req.selected_foods else []
  86. except json.JSONDecodeError:
  87. selected_foods = []
  88. try:
  89. participants = json.loads(req.participants) if req.participants else []
  90. except json.JSONDecodeError:
  91. participants = []
  92. participant_count = len(participants) if participants else 1
  93. foods_text = ", ".join([f.get("name", "") for f in selected_foods]) if selected_foods else "根据可用食材"
  94. constraints = []
  95. if req.allergies:
  96. constraints.append(f"禁忌: {req.allergies}")
  97. if req.health_goals:
  98. constraints.append(f"健康目标: {req.health_goals}")
  99. if req.cuisine_pref:
  100. constraints.append(f"菜系偏好: {req.cuisine_pref}")
  101. if req.spice_level is not None:
  102. constraints.append(f"辣度: {req.spice_level}/5")
  103. # 菜数要求(CR-C / R-05):注入既有 {constraints_text} 占位符,不新增占位符(F-18),
  104. # 因此 DB 模板(get_prompt)与本地模板两个来源同时生效;不依赖 prompt 的正确性,
  105. # 菜数硬保证由 Java DietRecommendationService.normalizeMenu 兜底。
  106. # Java AiGateway 传 dish_count = D(N);旧调用方不传时按 participant_count 现算,行为一致。
  107. effective_dish_count = req.dish_count if req.dish_count is not None else 4 + max(0, participant_count - 3)
  108. constraints.append(
  109. f"菜数硬要求: 午餐和晚餐各需 {effective_dish_count} 道菜 + 1 道汤"
  110. f"(D(N)=4+max(0,N-3),汤恒 1 且不计入菜数、只加菜不加汤;"
  111. f"汤标 type=soup、菜标 type=dish,汤恰好 1 道且排在最后);早餐维持 1-2 道。"
  112. )
  113. constraints_text = "\n".join(constraints) if constraints else "无特殊限制"
  114. role = await get_prompt("menu_generate_role") or "你是一个专业营养师和厨师,擅长根据食材和健康目标设计食谱。"
  115. template = await get_prompt("menu_generate") or MENU_GENERATE_TEMPLATE
  116. try:
  117. prompt = template.format(
  118. participant_count=participant_count,
  119. date=req.date,
  120. foods_text=foods_text,
  121. constraints_text=constraints_text,
  122. )
  123. except (KeyError, IndexError, ValueError):
  124. logger.warning("Java 配置的 menu_generate 模板缺少占位符,回退本地模板")
  125. prompt = MENU_GENERATE_TEMPLATE.format(
  126. participant_count=participant_count,
  127. date=req.date,
  128. foods_text=foods_text,
  129. constraints_text=constraints_text,
  130. )
  131. llm = ChatOpenAI(
  132. # agnes-2.5-flash:非推理模型,菜单生成 11s(deepseek 推理型会占满 token 导致 content 为空)
  133. model="agnes-2.5-flash",
  134. api_key=settings.llm_api_key,
  135. base_url=settings.llm_base_url,
  136. temperature=0.7,
  137. max_tokens=3000,
  138. )
  139. response = llm.invoke([
  140. SystemMessage(content=role),
  141. HumanMessage(content=prompt),
  142. ])
  143. text = response.content.strip()
  144. match = re.search(r'\{[\s\S]*\}', text)
  145. menu_json = match.group() if match else '{"meals": []}'
  146. return MenuGenerateResponse(menu_json=menu_json, trace_id=trace_id)
  147. except Exception as e:
  148. logger.error("菜单生成失败: %s", e, exc_info=True)
  149. return MenuGenerateResponse(menu_json='{"meals": []}', trace_id=trace_id)