Переглянути джерело

feat: 添加饮食模块 AI 接口(食材识别+菜单生成)

iwt 1 місяць тому
батько
коміт
d22e79e7f3

+ 135 - 0
cfc-langgraph/app/api/meal.py

@@ -0,0 +1,135 @@
+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
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["meal"])
+
+
+@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,
+        )
+
+        prompt = f"""请识别以下图片中的主要食材(3-8种),按置信度排序。
+返回 JSON 数组格式,不要包含其他内容:
+[{{"name": "食材名称", "confidence": 0.95, "category": "蔬菜/水果/肉禽/水产/蛋奶/谷物/调味/其他"}}]
+
+图片链接: {req.image_url}
+要求: 只返回 JSON 数组,不要有任何其他文字。"""
+
+        response = llm.invoke([
+            SystemMessage(content="你是一个专业的食材识别助手。"),
+            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 "无特殊限制"
+
+        prompt = f"""请为{participant_count}人生成{req.date}的一日三餐菜单。
+
+可用食材: {foods_text}
+用餐人数: {participant_count}人
+{constraints_text}
+
+请返回 JSON 格式:
+{{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "烹饪方法", "nutrition": {{"calories": 200, "protein": 10, "carbs": 30, "fat": 5}}, "notes": "备注"}}]}}]}}
+
+要求:
+1. 早/午/晚各至少1-2道菜
+2. 食材用量按{participant_count}人份计算
+3. 营养均衡,考虑健康目标
+4. 只用提供的食材
+5. 只返回 JSON,不要其他文字"""
+
+        llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=0.7,
+        )
+
+        response = llm.invoke([
+            SystemMessage(content="你是一个专业营养师和厨师,擅长根据食材和健康目标设计食谱。"),
+            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)

+ 2 - 1
cfc-langgraph/app/main.py

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal
 from app import monitoring
 from src.app import router as questionnaire_router
 
@@ -20,6 +20,7 @@ app.include_router(adapter.router)
 app.include_router(monitoring.router)
 app.include_router(questionnaire_router)
 app.include_router(report_parse.router)
+app.include_router(meal.router)
 
 
 @app.middleware("http")

+ 48 - 0
cfc-langgraph/app/models/meal.py

@@ -0,0 +1,48 @@
+from pydantic import BaseModel
+from typing import Optional, List
+
+
+class FoodRecognizeRequest(BaseModel):
+    image_url: str
+    prompt: str = "识别图片中的主要食材,返回 JSON 格式:[{name, confidence, category}]"
+
+
+class FoodsItem(BaseModel):
+    name: str
+    confidence: float
+    category: str = "unknown"
+
+
+class FoodRecognizeResponse(BaseModel):
+    foods: List[FoodsItem]
+    raw_response: str = ""
+    trace_id: str = ""
+
+
+class MenuGenerateRequest(BaseModel):
+    selected_foods: str
+    participants: str
+    date: str
+    allergies: Optional[str] = None
+    avoid_foods: Optional[str] = None
+    health_goals: Optional[str] = None
+    spice_level: Optional[int] = None
+    cuisine_pref: Optional[str] = None
+
+
+class MenuIngredient(BaseModel):
+    name: str
+    grams: int
+
+
+class MenuDish(BaseModel):
+    name: str
+    ingredients: List[MenuIngredient]
+    cooking_method: str = ""
+    nutrition: Optional[dict] = None
+    notes: str = ""
+
+
+class MenuGenerateResponse(BaseModel):
+    menu_json: str = ""
+    trace_id: str = ""