| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- import uuid
- import json
- import logging
- import re
- from fastapi import APIRouter
- from app.models.meal import (
- FoodRecognizeRequest,
- FoodRecognizeResponse,
- FoodsItem,
- MenuGenerateRequest,
- MenuGenerateResponse,
- )
- from app.config import settings
- from app.prompt_service import get_prompt
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/api/v1", tags=["meal"])
- FOOD_RECOGNIZE_TEMPLATE = """请识别以下图片中的主要食材(3-8种),按置信度排序。
- 返回 JSON 数组格式,不要包含其他内容:
- [{{"name": "食材名称", "confidence": 0.95, "category": "蔬菜/水果/肉禽/水产/蛋奶/谷物/调味/其他"}}]
- 图片链接: {image_url}
- 要求: 只返回 JSON 数组,不要有任何其他文字。"""
- MENU_GENERATE_TEMPLATE = """请为{participant_count}人生成{date}的一日三餐菜单。
- 可用食材: {foods_text}
- 用餐人数: {participant_count}人
- {constraints_text}
- 请返回 JSON 格式:
- {{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "做法一句话", "nutrition": {{"calories": 200}}}}]}}]}}
- 要求:
- 1. 早/午/晚各至少1-2道菜
- 2. 食材用量按{participant_count}人份计算
- 3. 营养均衡,考虑健康目标
- 4. 只用提供的食材
- 5. 只返回 JSON,不要其他文字"""
- @router.post("/food/recognize", response_model=FoodRecognizeResponse)
- async def recognize_food(req: FoodRecognizeRequest):
- """食材识别: 上传图片 URL, 返回识别食材列表"""
- trace_id = str(uuid.uuid4())
- try:
- from langchain_openai import ChatOpenAI
- from langchain_core.messages import HumanMessage, SystemMessage
- llm = ChatOpenAI(
- model=settings.llm_model,
- api_key=settings.llm_api_key,
- base_url=settings.llm_base_url,
- temperature=0,
- )
- role = await get_prompt("food_recognize_role") or "你是一个专业的食材识别助手。"
- template = await get_prompt("food_recognize") or FOOD_RECOGNIZE_TEMPLATE
- try:
- prompt = template.format(image_url=req.image_url)
- except (KeyError, IndexError, ValueError):
- logger.warning("Java 配置的 food_recognize 模板缺少占位符,回退本地模板")
- prompt = FOOD_RECOGNIZE_TEMPLATE.format(image_url=req.image_url)
- response = llm.invoke([
- SystemMessage(content=role),
- HumanMessage(content=prompt),
- ])
- text = response.content.strip()
- try:
- foods = json.loads(text)
- except json.JSONDecodeError:
- match = re.search(r'\[[\s\S]*\]', text)
- foods = json.loads(match.group()) if match else []
- foods_list = []
- for item in foods:
- if isinstance(item, dict):
- foods_list.append(FoodsItem(
- name=item.get("name", "未知"),
- confidence=float(item.get("confidence", 0.5)),
- category=item.get("category", "other"),
- ))
- return FoodRecognizeResponse(foods=foods_list, raw_response=text, trace_id=trace_id)
- except Exception as e:
- logger.error("食材识别失败: %s", e, exc_info=True)
- return FoodRecognizeResponse(foods=[], trace_id=trace_id)
- @router.post("/menu/generate", response_model=MenuGenerateResponse)
- async def generate_menu(req: MenuGenerateRequest):
- """菜单生成: 根据食材和用餐人数生成一日三餐菜单"""
- trace_id = str(uuid.uuid4())
- try:
- from langchain_openai import ChatOpenAI
- from langchain_core.messages import HumanMessage, SystemMessage
- try:
- selected_foods = json.loads(req.selected_foods) if req.selected_foods else []
- except json.JSONDecodeError:
- selected_foods = []
- try:
- participants = json.loads(req.participants) if req.participants else []
- except json.JSONDecodeError:
- participants = []
- participant_count = len(participants) if participants else 1
- foods_text = ", ".join([f.get("name", "") for f in selected_foods]) if selected_foods else "根据可用食材"
- constraints = []
- if req.allergies:
- constraints.append(f"禁忌: {req.allergies}")
- if req.health_goals:
- constraints.append(f"健康目标: {req.health_goals}")
- if req.cuisine_pref:
- constraints.append(f"菜系偏好: {req.cuisine_pref}")
- if req.spice_level is not None:
- constraints.append(f"辣度: {req.spice_level}/5")
- constraints_text = "\n".join(constraints) if constraints else "无特殊限制"
- role = await get_prompt("menu_generate_role") or "你是一个专业营养师和厨师,擅长根据食材和健康目标设计食谱。"
- template = await get_prompt("menu_generate") or MENU_GENERATE_TEMPLATE
- try:
- prompt = template.format(
- participant_count=participant_count,
- date=req.date,
- foods_text=foods_text,
- constraints_text=constraints_text,
- )
- except (KeyError, IndexError, ValueError):
- logger.warning("Java 配置的 menu_generate 模板缺少占位符,回退本地模板")
- prompt = MENU_GENERATE_TEMPLATE.format(
- participant_count=participant_count,
- date=req.date,
- foods_text=foods_text,
- constraints_text=constraints_text,
- )
- llm = ChatOpenAI(
- # agnes-2.5-flash:非推理模型,菜单生成 11s(deepseek 推理型会占满 token 导致 content 为空)
- model="agnes-2.5-flash",
- api_key=settings.llm_api_key,
- base_url=settings.llm_base_url,
- temperature=0.7,
- max_tokens=3000,
- )
- response = llm.invoke([
- SystemMessage(content=role),
- HumanMessage(content=prompt),
- ])
- text = response.content.strip()
- match = re.search(r'\{[\s\S]*\}', text)
- menu_json = match.group() if match else '{"meals": []}'
- return MenuGenerateResponse(menu_json=menu_json, trace_id=trace_id)
- except Exception as e:
- logger.error("菜单生成失败: %s", e, exc_info=True)
- return MenuGenerateResponse(menu_json='{"meals": []}', trace_id=trace_id)
|